diff --git a/backend/config/cloudinary.js b/backend/config/cloudinary.js
new file mode 100644
index 0000000..ea9e7e6
--- /dev/null
+++ b/backend/config/cloudinary.js
@@ -0,0 +1,9 @@
+const cloudinary = require("cloudinary").v2;
+
+cloudinary.config({
+ cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
+ api_key: process.env.CLOUDINARY_API_KEY,
+ api_secret: process.env.CLOUDINARY_API_SECRET,
+});
+
+module.exports = cloudinary;
\ No newline at end of file
diff --git a/backend/config/db.js b/backend/config/db.js
index 369ce73..b38b77a 100644
--- a/backend/config/db.js
+++ b/backend/config/db.js
@@ -1,11 +1,15 @@
// config/db.js
const mongoose = require("mongoose");
-if (!process.env.MONGO_URI) {
- throw new Error("Missing MONGO_URI environment variable");
-}
-
const connectDB = async () => {
+ if (!process.env.MONGO_URI) {
+ if (process.env.NODE_ENV === "test") {
+ return;
+ }
+
+ throw new Error("Missing MONGO_URI environment variable");
+ }
+
let retries = 1;
const delay = 1000; // 1s backoff if you want to bump retries later
diff --git a/backend/models/AssignmentSubmission.js b/backend/models/AssignmentSubmission.js
new file mode 100644
index 0000000..407ec9a
--- /dev/null
+++ b/backend/models/AssignmentSubmission.js
@@ -0,0 +1,107 @@
+const mongoose = require('mongoose');
+
+const GradeSchema = new mongoose.Schema({
+ points: { type: Number, required: true },
+ maxPoints: { type: Number, required: true },
+ comment: { type: String, default: '' }
+}, { _id: false });
+
+const AssignmentSubmissionSchema = new mongoose.Schema({
+ // Student Info
+ student: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true
+ },
+ studentName: { type: String, required: true },
+ studentEmail: { type: String, required: true },
+
+ // Course Info
+ course: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Course',
+ required: true
+ },
+ courseName: { type: String, required: true },
+
+ module: {
+ type: mongoose.Schema.Types.ObjectId,
+ required: true
+ },
+ moduleName: { type: String, required: true },
+
+ // Assignment Details
+ lessonId: { type: String, required: true }, // The generated lesson ID
+ assignmentTitle: { type: String, required: true },
+ assignmentType: {
+ type: String,
+ enum: ['question-based', 'part-based'],
+ default: 'part-based'
+ },
+
+ // OLD: Part-based assignment data
+ assignmentData: {
+ parts: [{
+ partNumber: Number,
+ title: String,
+ instructions: String
+ }],
+ gradingCriteria: [{
+ name: String,
+ points: Number
+ }]
+ },
+
+ // Student Submission
+ submittedAt: { type: Date, default: Date.now },
+
+ // OLD: Part-based answers
+ partAnswers: {
+ type: Map,
+ of: String,
+ default: {}
+ },
+
+ // NEW: Question-based answers
+ answers: {
+ type: Map,
+ of: mongoose.Schema.Types.Mixed,
+ default: {}
+ },
+
+ fileUrl: { type: String, default: '' },
+
+ // Grading
+ status: {
+ type: String,
+ enum: ['pending', 'graded'],
+ default: 'pending'
+ },
+ grades: {
+ type: Map,
+ of: GradeSchema,
+ default: {}
+ },
+ totalScore: { type: Number, default: 0 },
+ maxScore: { type: Number, required: true },
+ passed: { type: Boolean, default: false },
+ passingScore: { type: Number, default: 70 }, // percentage
+ overallFeedback: { type: String, default: '' },
+ gradedAt: { type: Date },
+
+ // Instructor
+ instructor: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true
+ }
+}, {
+ timestamps: true
+});
+
+// Index for quick queries
+AssignmentSubmissionSchema.index({ instructor: 1, status: 1 });
+AssignmentSubmissionSchema.index({ student: 1, course: 1 });
+AssignmentSubmissionSchema.index({ course: 1, status: 1 });
+
+module.exports = mongoose.model('AssignmentSubmission', AssignmentSubmissionSchema);
\ No newline at end of file
diff --git a/backend/models/CourseModel.js b/backend/models/CourseModel.js
index db850e0..6f334cb 100644
--- a/backend/models/CourseModel.js
+++ b/backend/models/CourseModel.js
@@ -1,10 +1,12 @@
const mongoose = require("mongoose");
const InstructorSchema = new mongoose.Schema({
+ _id: { type: mongoose.Schema.Types.ObjectId, required: true }, // ADD THIS LINE
name: { type: String, required: true },
title: { type: String, default: "" },
bio: { type: String, default: "" },
- avatar: { type: String, default: "" }
+ avatar: { type: String, default: "" },
+ email: { type: String, default: "" }
}, { _id: false });
const PricingSchema = new mongoose.Schema({
@@ -22,27 +24,167 @@ const SubscriptionSchema = new mongoose.Schema({
tier: { type: String, default: "" }
}, { _id: false });
+// NEW: Learning Materials Schema
+const LearningMaterialsSchema = new mongoose.Schema({
+ readings: [{
+ title: { type: String },
+ author: { type: String },
+ citation: { type: String },
+ link: { type: String }
+ }],
+ podcasts: [{
+ title: { type: String },
+ link: { type: String }
+ }],
+ videos: [{
+ title: { type: String },
+ link: { type: String }
+ }]
+}, { _id: false });
+
+// NEW: Assignment Question Schema with different types
+const AssignmentQuestionSchema = new mongoose.Schema({
+ questionNumber: { type: Number, required: true },
+ type: {
+ type: String,
+ enum: ['multiple-choice', 'written', 'matching', 'pdf-upload', 'true-false'],
+ required: true
+ },
+
+ // Common fields
+ question: { type: String, required: true },
+ points: { type: Number, default: 10 },
+
+ // For multiple-choice
+ options: [{ type: String }],
+ correctAnswer: { type: Number }, // index of correct option
+
+ // For matching
+ matchPairs: [{
+ left: { type: String },
+ right: { type: String }
+ }],
+
+ // For written response
+ wordLimit: { type: Number },
+ rubric: { type: String },
+
+ // For PDF upload
+ fileRequirements: { type: String }
+
+}, { _id: false });
+
+// Updated Assignment Schema
+const AssignmentSchema = new mongoose.Schema({
+ title: { type: String, required: true },
+ purpose: { type: String },
+ instructions: { type: String },
+
+ // NEW: Question-based structure
+ questions: [AssignmentQuestionSchema],
+
+ // DEPRECATED: Old part-based structure (keep for backward compatibility)
+ parts: [{
+ partNumber: { type: Number },
+ title: { type: String },
+ instructions: { type: String }
+ }],
+
+ gradingCriteria: [{
+ name: { type: String },
+ points: { type: Number }
+ }],
+
+ deliverableFormat: { type: String },
+ totalPoints: { type: Number, default: 100 }
+
+}, { _id: false });
+
+// Lesson Schema (videos, assignments, quizzes within a module)
+const LessonSchema = new mongoose.Schema({
+ type: {
+ type: String,
+ enum: ["video", "assignment", "quiz"],
+ required: true
+ },
+ title: { type: String, required: true },
+ order: { type: Number, required: true },
+
+ // For VIDEO lessons
+ videoUrl: { type: String, default: "" },
+ duration: { type: String, default: "" },
+
+ // For ASSIGNMENT lessons
+ assignmentType: {
+ type: String,
+ enum: ["text", "file", "both"],
+ default: "text"
+ },
+ instructions: { type: String, default: "" },
+
+ // For QUIZ lessons
+ questions: [{
+ question: { type: String },
+ questionType: {
+ type: String,
+ enum: ["multiple-choice", "short-answer"],
+ default: "multiple-choice"
+ },
+ options: [{ type: String }], // for multiple choice
+ correctAnswer: { type: String }, // answer text or option index
+ points: { type: Number, default: 1 }
+ }],
+ passingScore: { type: Number, default: 70 }, // percentage
+
+}, { _id: true });
+
const ModuleSchema = new mongoose.Schema({
title: { type: String, required: true },
description: { type: String, default: "" },
-
- // Content types
+ order: { type: Number, default: 0 },
+
+ // NEW: Academic structure
+ learningOutcomes: { type: [String], default: [] },
+ learningMaterials: { type: LearningMaterialsSchema, default: () => ({ readings: [], podcasts: [], videos: [] }) },
+ assignment: { type: AssignmentSchema, default: null },
+
+ // Array of lessons (kept for compatibility)
+ lessons: { type: [LessonSchema], default: [] },
+
+ // DEPRECATED: Old fields kept for backward compatibility
videoUrl: { type: String, default: "" },
articleContent: { type: String, default: "" },
pdfUrl: { type: String, default: "" },
-
- // Quiz placeholder for future
hasQuiz: { type: Boolean, default: false },
quizData: { type: Object, default: null },
-
learningPoints: { type: [String], default: [] },
duration: { type: String, default: "" },
estimatedMinutes: { type: Number, default: 0 },
thumbnail: { type: String, default: "" },
-
- order: { type: Number, default: 0 }
}, { _id: true });
+// Final Test Schema
+const FinalTestSchema = new mongoose.Schema({
+ title: { type: String, default: "Final Test" },
+ description: { type: String, default: "" },
+ passingScore: { type: Number, default: 70 },
+ questions: [{
+ question: { type: String, required: true },
+ options: [{ type: String }],
+ correctAnswer: { type: Number },
+ points: { type: Number, default: 1 }
+ }],
+ timeLimit: { type: Number, default: 0 },
+}, { _id: false });
+
+// Course Badge Schema
+const BadgeSchema = new mongoose.Schema({
+ name: { type: String, default: "" },
+ description: { type: String, default: "" },
+ imageUrl: { type: String, default: "" },
+ color: { type: String, default: "#4F46E5" }
+}, { _id: false });
+
const CourseSchema = new mongoose.Schema({
title: { type: String, required: true },
description: { type: String, required: true },
@@ -75,7 +217,16 @@ const CourseSchema = new mongoose.Schema({
learningPoints: { type: [String], default: [] },
modules: { type: [ModuleSchema], default: [] },
-
+
+ finalTest: {
+ type: FinalTestSchema,
+ default: null
+ },
+
+ badge: {
+ type: BadgeSchema,
+ default: () => ({})
+ },
},
{
timestamps: true,
@@ -83,7 +234,6 @@ const CourseSchema = new mongoose.Schema({
);
CourseSchema.virtual("priceAmount").get(function () {
- // 1) If pricing.amount is set, use that
if (
this.pricing &&
typeof this.pricing.amount === "number" &&
@@ -92,7 +242,6 @@ CourseSchema.virtual("priceAmount").get(function () {
return this.pricing.amount;
}
- // 2) If the raw document (e.g., created via Atlas) has priceAmount, use it
if (this._doc && typeof this._doc.priceAmount === "number") {
return this._doc.priceAmount;
}
@@ -100,17 +249,20 @@ CourseSchema.virtual("priceAmount").get(function () {
return 0;
});
-// Virtual: whether the course counts as free
CourseSchema.virtual("isFree").get(function () {
const price = this.priceAmount;
return this.isLiteVersion || price === 0;
});
-// Make sure virtuals show up in JSON sent to frontend
+CourseSchema.virtual("totalLessons").get(function () {
+ return this.modules.reduce((total, module) => {
+ return total + (module.lessons?.length || 0);
+ }, 0);
+});
+
CourseSchema.set("toJSON", { virtuals: true });
CourseSchema.set("toObject", { virtuals: true });
-// Indexes to support search & filtering (optional but good to add)
CourseSchema.index({
title: "text",
description: "text",
@@ -121,4 +273,4 @@ CourseSchema.index({ category: 1 });
CourseSchema.index({ "pricing.amount": 1 });
CourseSchema.index({ createdAt: -1 });
-module.exports = mongoose.model("Course", CourseSchema);
+module.exports = mongoose.model("Course", CourseSchema);
\ No newline at end of file
diff --git a/backend/models/SeminarModel.js b/backend/models/SeminarModel.js
index 20133aa..a520d20 100644
--- a/backend/models/SeminarModel.js
+++ b/backend/models/SeminarModel.js
@@ -23,9 +23,14 @@ const SeminarSchema = new mongoose.Schema({
// Scheduling info
schedule: {
- date: { type: String }, // YYYY-MM-DD
- time: { type: String }, // HH:MM
- joinUrl: { type: String }
+ date: { type: String }, // Legacy: YYYY-MM-DD
+ time: { type: String }, // Legacy: HH:MM
+ joinUrl: { type: String },
+ startAt: { type: Date },
+ endAt: { type: Date },
+ sourceTimezone: { type: String },
+ zoomMeetingId: { type: String },
+ zoomPassword: { type: String }
},
createdAt: { type: Date, default: Date.now }
diff --git a/backend/models/TutorialModel.js b/backend/models/TutorialModel.js
index 8b86343..be97635 100644
--- a/backend/models/TutorialModel.js
+++ b/backend/models/TutorialModel.js
@@ -12,6 +12,11 @@ const TutorialSchema = new mongoose.Schema({
videoUrl: { type: String }, // YouTube, Vimeo, etc.
writtenContent: { type: String }, // Long text section
+ // INSTRUCTOR
+ instructor: {
+ name: { type: String, required: true }
+ },
+
// Downloadable Resources
resources: [
{
diff --git a/backend/models/UserModel.js b/backend/models/UserModel.js
index fbbbe26..2e0c5b5 100644
--- a/backend/models/UserModel.js
+++ b/backend/models/UserModel.js
@@ -1,7 +1,77 @@
const mongoose = require("mongoose");
-const bcrypt = require("bcrypt"); // <-- NEW
-
-const SALT_ROUNDS = Number(process.env.BCRYPT_SALT_ROUNDS || 10); // <-- NEW
+const bcrypt = require("bcrypt");
+
+const SALT_ROUNDS = Number(process.env.BCRYPT_SALT_ROUNDS || 10);
+
+// Assignment Submission Schema
+const AssignmentSubmissionSchema = new mongoose.Schema({
+ lessonId: { type: mongoose.Schema.Types.Mixed, required: true },
+ textSubmission: { type: String, default: "" },
+ fileUrl: { type: String, default: "" },
+ submittedAt: { type: Date, default: Date.now },
+ grade: { type: Number, default: null },
+ feedback: { type: String, default: "" }
+}, { _id: false });
+
+// Quiz Result Schema
+const QuizResultSchema = new mongoose.Schema({
+ lessonId: { type: mongoose.Schema.Types.Mixed, required: true },
+ score: { type: Number, required: true }, // percentage
+ totalQuestions: { type: Number, required: true },
+ correctAnswers: { type: Number, required: true },
+ answers: [{ type: mongoose.Schema.Types.Mixed }], // user's answers
+ passed: { type: Boolean, required: true },
+ attemptedAt: { type: Date, default: Date.now }
+}, { _id: false });
+
+// Course Progress Schema
+const CourseProgressSchema = new mongoose.Schema({
+ courseId: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Course",
+ required: true
+ },
+
+ // Tracking completed lessons (can be ObjectIds OR custom strings like "podcast-0")
+ completedLessons: [{
+ type: mongoose.Schema.Types.Mixed // ← CHANGED from ObjectId to Mixed
+ }],
+
+ // Current position in course (can be ObjectIds OR custom strings)
+ currentModuleId: { type: mongoose.Schema.Types.Mixed },
+ currentLessonId: { type: mongoose.Schema.Types.Mixed },
+
+ // Assignment submissions
+ assignmentSubmissions: [AssignmentSubmissionSchema],
+
+ // Quiz results
+ quizResults: [QuizResultSchema],
+
+ // Final test
+ finalTestScore: { type: Number, default: null },
+ finalTestPassed: { type: Boolean, default: false },
+ finalTestAttempts: { type: Number, default: 0 },
+
+ // Completion tracking
+ isCompleted: { type: Boolean, default: false },
+ completedAt: { type: Date, default: null },
+ badgeEarned: { type: Boolean, default: false },
+
+ // Progress percentage
+ progressPercentage: { type: Number, default: 0 },
+
+ lastAccessedAt: { type: Date, default: Date.now }
+}, { _id: true });
+
+// Learning Time Tracker (overall + per-course)
+const LearningByCourseSchema = new mongoose.Schema(
+ {
+ courseId: { type: mongoose.Schema.Types.ObjectId, ref: "Course", required: true },
+ totalSeconds: { type: Number, default: 0, min: 0 },
+ lastTrackedAt: { type: Date, default: Date.now }
+ },
+ { _id: false }
+);
const UserSchema = new mongoose.Schema({
// Basic Identity
@@ -24,10 +94,10 @@ const UserSchema = new mongoose.Schema({
unique: true
},
- password: { type: String, required: true }, // now stored as hash
+ password: { type: String, required: true },
// User Permissions
- role: {
+ accountType: {
type: String,
enum: ["student", "instructor", "admin"],
default: "student",
@@ -42,6 +112,9 @@ const UserSchema = new mongoose.Schema({
{ type: mongoose.Schema.Types.ObjectId, ref: "Course" }
],
+ // Detailed course progress tracking
+ courseProgress: [CourseProgressSchema],
+
registeredSeminars: [
{ type: mongoose.Schema.Types.ObjectId, ref: "Seminar" }
],
@@ -57,15 +130,20 @@ const UserSchema = new mongoose.Schema({
savedPodcasts: [
{ type: mongoose.Schema.Types.ObjectId, ref: "Podcast" }
],
+
+ // Total Learning Time Tracker (store seconds; convert to hours in UI)
+ learning: {
+ totalSeconds: { type: Number, default: 0, min: 0 },
+ byCourse: { type: [LearningByCourseSchema], default: [] },
+ updatedAt: { type: Date, default: Date.now }
+ },
},
{
timestamps: true,
}
);
-// ------------------------------------------------------
// Password hashing
-// ------------------------------------------------------
UserSchema.pre("save", async function (next) {
if (!this.isModified("password")) return next();
@@ -78,10 +156,12 @@ UserSchema.pre("save", async function (next) {
}
});
-// Fast lookups for login + registration
-UserSchema.index({ role: 1 });
+// Fast lookups
+UserSchema.index({ accountType: 1 });
UserSchema.index({ enrolledCourses: 1 });
UserSchema.index({ completedCourses: 1 });
UserSchema.index({ bookmarkedTutorials: 1 });
+UserSchema.index({ "courseProgress.courseId": 1 });
+UserSchema.index({ "learning.byCourse.courseId": 1 });
module.exports = mongoose.model("User", UserSchema);
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 0db78c9..4686f55 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -15,6 +15,7 @@
"bytes": "^3.1.2",
"call-bind-apply-helpers": "^1.0.2",
"call-bound": "^1.0.4",
+ "cloudinary": "^2.9.0",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.2",
@@ -52,6 +53,7 @@
"mime-types": "^3.0.1",
"mongoose": "^8.19.3",
"ms": "^2.1.3",
+ "multer": "^2.1.1",
"negotiator": "^1.0.0",
"object-assign": "^4.1.1",
"on-finished": "^2.4.1",
@@ -80,9 +82,20 @@
"wrappy": "^1.0.2"
},
"devDependencies": {
+ "concurrently": "^8.2.2",
"nodemon": "^3.1.10"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@mongodb-js/saslprep": {
"version": "1.3.2",
"license": "MIT",
@@ -112,6 +125,32 @@
"node": ">= 0.6"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/anymatch": {
"version": "3.1.3",
"dev": true,
@@ -124,6 +163,12 @@
"node": ">= 8"
}
},
+ "node_modules/append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+ "license": "MIT"
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"dev": true,
@@ -219,6 +264,23 @@
"version": "1.0.1",
"license": "BSD-3-Clause"
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
"node_modules/bytes": {
"version": "3.1.2",
"license": "MIT",
@@ -251,6 +313,46 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chalk/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"dev": true,
@@ -274,11 +376,127 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cloudinary": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.9.0.tgz",
+ "integrity": "sha512-F3iKMOy4y0zy0bi5JBp94SC7HY7i/ImfTPSUV07iJmRzH1Iz8WavFfOlJTR1zvYM/xKGoiGZ3my/zy64In0IQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.21"
+ },
+ "engines": {
+ "node": ">=9"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"dev": true,
"license": "MIT"
},
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "engines": [
+ "node >= 6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
+ "node_modules/concurrently": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
+ "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.2",
+ "date-fns": "^2.30.0",
+ "lodash": "^4.17.21",
+ "rxjs": "^7.8.1",
+ "shell-quote": "^1.8.1",
+ "spawn-command": "0.0.2",
+ "supports-color": "^8.1.1",
+ "tree-kill": "^1.2.2",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "conc": "dist/bin/concurrently.js",
+ "concurrently": "dist/bin/concurrently.js"
+ },
+ "engines": {
+ "node": "^14.13.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/concurrently/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/concurrently/node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
"node_modules/content-disposition": {
"version": "1.0.0",
"license": "MIT",
@@ -339,6 +557,23 @@
"node": ">= 0.10"
}
},
+ "node_modules/date-fns": {
+ "version": "2.30.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
+ "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.21.0"
+ },
+ "engines": {
+ "node": ">=0.11"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/date-fns"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"license": "MIT",
@@ -394,6 +629,13 @@
"version": "1.1.1",
"license": "MIT"
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"license": "MIT",
@@ -425,6 +667,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"license": "MIT"
@@ -538,6 +790,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"license": "MIT",
@@ -683,6 +945,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"dev": true,
@@ -750,6 +1022,12 @@
"node": ">=12.0.0"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"version": "4.3.0",
"license": "MIT"
@@ -920,6 +1198,68 @@
"version": "2.1.3",
"license": "MIT"
},
+ "node_modules/multer": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
+ "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
+ "license": "MIT",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.6.0",
+ "concat-stream": "^2.0.0",
+ "type-is": "^1.6.18"
+ },
+ "engines": {
+ "node": ">= 10.16.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/multer/node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/multer/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/multer/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/multer/node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/negotiator": {
"version": "1.0.0",
"license": "MIT",
@@ -1102,6 +1442,20 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
@@ -1113,6 +1467,16 @@
"node": ">=8.10.0"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/router": {
"version": "2.2.0",
"license": "MIT",
@@ -1127,6 +1491,16 @@
"node": ">= 18"
}
},
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"funding": [
@@ -1196,6 +1570,19 @@
"version": "1.2.0",
"license": "ISC"
},
+ "node_modules/shell-quote": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/side-channel": {
"version": "1.1.0",
"license": "MIT",
@@ -1322,6 +1709,12 @@
"memory-pager": "^1.0.2"
}
},
+ "node_modules/spawn-command": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
+ "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
+ "dev": true
+ },
"node_modules/statuses": {
"version": "2.0.2",
"license": "MIT",
@@ -1329,6 +1722,51 @@
"node": ">= 0.8"
}
},
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/stripe": {
"version": "20.1.2",
"resolved": "https://registry.npmmirror.com/stripe/-/stripe-20.1.2.tgz",
@@ -1396,6 +1834,23 @@
"node": ">=18"
}
},
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
"node_modules/type-is": {
"version": "2.0.1",
"license": "MIT",
@@ -1408,6 +1863,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT"
+ },
"node_modules/undefsafe": {
"version": "2.0.5",
"dev": true,
@@ -1420,6 +1881,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/vary": {
"version": "1.1.2",
"license": "MIT",
@@ -1445,9 +1912,66 @@
"node": ">=18"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"license": "ISC"
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
}
}
}
diff --git a/backend/package.json b/backend/package.json
index bd818b9..b0c5086 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -5,7 +5,12 @@
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
- "start": "node server.js"
+ "start": "node server.js",
+ "server": "nodemon server.js",
+ "client": "npm start --prefix ../frontend",
+ "client-build": "npm run build --prefix ../frontend",
+ "build": "npm install && npm install --prefix ../frontend && npm run build --prefix ../frontend",
+ "dev": "concurrently \"npm run server\" \"npm run client\""
},
"keywords": [],
"author": "",
@@ -18,6 +23,7 @@
"bytes": "^3.1.2",
"call-bind-apply-helpers": "^1.0.2",
"call-bound": "^1.0.4",
+ "cloudinary": "^2.9.0",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.2",
@@ -55,6 +61,7 @@
"mime-types": "^3.0.1",
"mongoose": "^8.19.3",
"ms": "^2.1.3",
+ "multer": "^2.1.1",
"negotiator": "^1.0.0",
"object-assign": "^4.1.1",
"on-finished": "^2.4.1",
@@ -83,6 +90,7 @@
"wrappy": "^1.0.2"
},
"devDependencies": {
+ "concurrently": "^8.2.2",
"nodemon": "^3.1.10"
}
}
diff --git a/backend/routes/assignments.js b/backend/routes/assignments.js
new file mode 100644
index 0000000..e6f4b52
--- /dev/null
+++ b/backend/routes/assignments.js
@@ -0,0 +1,124 @@
+const express = require('express');
+const router = express.Router();
+const AssignmentSubmission = require('../models/AssignmentSubmission');
+const Course = require('../models/CourseModel');
+const User = require('../models/UserModel');
+const { protect } = require('../middleware/authMiddleware');
+
+// -------------------------------------
+// POST - Submit Assignment
+// -------------------------------------
+router.post('/submit', protect, async (req, res) => {
+ try {
+ const { courseId, moduleId, assignmentId, answers, fileUrl, submittedAt } = req.body;
+
+ console.log('=== ASSIGNMENT SUBMISSION ===');
+ console.log('User:', req.user);
+ console.log('Body:', req.body);
+
+ if (!courseId || !moduleId || !assignmentId) {
+ return res.status(400).json({ error: 'Missing required fields' });
+ }
+
+ // Get course and module to extract assignment data
+ const course = await Course.findById(courseId);
+ if (!course) {
+ return res.status(404).json({ error: 'Course not found' });
+ }
+
+ const module = course.modules.id(moduleId);
+ if (!module) {
+ return res.status(404).json({ error: 'Module not found' });
+ }
+
+ const assignment = module.assignment;
+ if (!assignment) {
+ return res.status(404).json({ error: 'Assignment not found' });
+ }
+
+ // Get student info
+ const student = await User.findById(req.user._id);
+ if (!student) {
+ return res.status(404).json({ error: 'Student not found' });
+ }
+
+ // Determine if question-based or part-based
+ const isQuestionBased = assignment.questions && assignment.questions.length > 0;
+
+ // Create submission with ALL required fields
+ const submission = new AssignmentSubmission({
+ student: req.user._id,
+ studentName: `${student.firstName} ${student.lastName}`,
+ studentEmail: student.email,
+
+ course: courseId,
+ courseName: course.title,
+
+ module: moduleId,
+ moduleName: module.title,
+
+ lessonId: assignmentId,
+ assignmentTitle: assignment.title || 'Module Assignment',
+ assignmentType: isQuestionBased ? 'question-based' : 'part-based',
+
+ // Assignment data (for backward compatibility)
+ assignmentData: {
+ parts: assignment.parts || [],
+ gradingCriteria: assignment.gradingCriteria || []
+ },
+
+ // Submission data
+ answers: answers || {},
+ partAnswers: new Map(), // Empty for new submissions
+ fileUrl: fileUrl || '',
+ submittedAt: submittedAt || new Date(),
+
+ // Grading info
+ maxScore: assignment.totalPoints || 100,
+ passingScore: 70,
+ status: 'pending',
+
+ instructor: course.instructor._id
+ });
+
+ await submission.save();
+
+ console.log('✅ Submission saved:', submission._id);
+
+ res.status(201).json({
+ message: 'Assignment submitted successfully',
+ submission
+ });
+
+ } catch (err) {
+ console.error('❌ Error submitting assignment:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// -------------------------------------
+// GET - Get student's submission for an assignment
+// -------------------------------------
+router.get('/:courseId/:assignmentId', protect, async (req, res) => {
+ try {
+ const { courseId, assignmentId } = req.params;
+
+ const submission = await AssignmentSubmission.findOne({
+ student: req.user._id,
+ course: courseId,
+ lessonId: assignmentId
+ });
+
+ if (!submission) {
+ return res.status(404).json({ error: 'Submission not found' });
+ }
+
+ res.json(submission);
+
+ } catch (err) {
+ console.error('Error fetching submission:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+module.exports = router;
\ No newline at end of file
diff --git a/backend/routes/courseProgress.js b/backend/routes/courseProgress.js
new file mode 100644
index 0000000..ce5acfe
--- /dev/null
+++ b/backend/routes/courseProgress.js
@@ -0,0 +1,329 @@
+const express = require("express");
+const User = require("../models/UserModel");
+const Course = require("../models/CourseModel");
+const AssignmentSubmission = require("../models/AssignmentSubmission");
+const { protect } = require("../middleware/authMiddleware");
+
+const router = express.Router();
+
+// ============================================
+// GET course progress for a user
+// ============================================
+router.get("/:courseId/progress", protect, async (req, res) => {
+ try {
+ const user = await User.findById(req.user._id);
+ const progress = user.courseProgress.find(
+ p => p.courseId.toString() === req.params.courseId
+ );
+
+ if (!progress) {
+ return res.json({
+ courseId: req.params.courseId,
+ completedLessons: [],
+ progressPercentage: 0,
+ currentModuleId: null,
+ currentLessonId: null
+ });
+ }
+
+ res.json(progress);
+ } catch (err) {
+ console.error("Error fetching progress:", err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ============================================
+// MARK lesson as complete
+// ============================================
+router.post("/:courseId/progress/lesson/:lessonId/complete", protect, async (req, res) => {
+ try {
+ const { courseId, lessonId } = req.params;
+
+ const user = await User.findById(req.user._id);
+ const course = await Course.findById(courseId);
+
+ if (!course) {
+ return res.status(404).json({ error: "Course not found" });
+ }
+
+ // Find or create progress entry
+ let progress = user.courseProgress.find(
+ p => p.courseId.toString() === courseId
+ );
+
+ if (!progress) {
+ progress = {
+ courseId,
+ completedLessons: [],
+ assignmentSubmissions: [],
+ quizResults: [],
+ lastAccessedAt: new Date()
+ };
+ user.courseProgress.push(progress);
+ }
+
+ // Add lesson to completed if not already there
+ // Convert lessonId to string for comparison since it might be a custom ID like "podcast-0"
+ const lessonIdStr = lessonId.toString();
+ if (!progress.completedLessons.some(id => id.toString() === lessonIdStr)) {
+ progress.completedLessons.push(lessonId);
+ }
+
+ // Update last accessed
+ progress.lastAccessedAt = new Date();
+
+ // Calculate progress percentage
+ const totalLessons = course.modules.reduce((total, module) => {
+ let count = module.lessons?.length || 0;
+ // Add learning materials
+ count += module.learningMaterials?.readings?.length || 0;
+ count += module.learningMaterials?.podcasts?.length || 0;
+ count += module.learningMaterials?.videos?.length || 0;
+ // Add assignment if exists
+ if (module.assignment) count += 1;
+ return total + count;
+ }, 0);
+
+ progress.progressPercentage = totalLessons > 0
+ ? Math.round((progress.completedLessons.length / totalLessons) * 100)
+ : 0;
+
+ await user.save();
+
+ res.json({
+ message: "Lesson marked as complete",
+ progress: progress
+ });
+
+ } catch (err) {
+ console.error("Error marking lesson complete:", err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ============================================
+// UPDATE current position (for "continue where you left off")
+// ============================================
+router.post("/:courseId/progress/position", protect, async (req, res) => {
+ try {
+ const { courseId } = req.params;
+ const { moduleId, lessonId } = req.body;
+
+ console.log('\n=== UPDATE POSITION REQUEST ===');
+ console.log('Course ID:', courseId);
+ console.log('Module ID:', moduleId);
+ console.log('Lesson ID:', lessonId);
+
+ const user = await User.findById(req.user._id);
+
+ let progress = user.courseProgress.find(
+ p => p.courseId.toString() === courseId
+ );
+
+ if (!progress) {
+ // Create new progress entry
+ progress = {
+ courseId,
+ completedLessons: [],
+ assignmentSubmissions: [],
+ quizResults: [],
+ currentModuleId: moduleId || null,
+ currentLessonId: lessonId || null,
+ lastAccessedAt: new Date()
+ };
+ user.courseProgress.push(progress);
+ console.log('Created new progress entry');
+ } else {
+ // Update existing progress
+ // Store as strings since lessonId might be custom IDs like "podcast-0"
+ progress.currentModuleId = moduleId || progress.currentModuleId;
+ progress.currentLessonId = lessonId || progress.currentLessonId;
+ progress.lastAccessedAt = new Date();
+ console.log('Updated existing progress');
+ }
+
+ await user.save();
+ console.log('Position saved successfully');
+
+ res.json({ message: "Position updated", progress });
+
+ } catch (err) {
+ console.error('\n❌ ERROR updating position:');
+ console.error('Message:', err.message);
+ console.error('Stack:', err.stack);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ============================================
+// SUBMIT quiz
+// ============================================
+router.post("/:courseId/progress/quiz/:lessonId/submit", protect, async (req, res) => {
+ try {
+ const { courseId, lessonId } = req.params;
+ const { answers } = req.body;
+
+ const user = await User.findById(req.user._id);
+ const course = await Course.findById(courseId);
+
+ if (!course) {
+ return res.status(404).json({ error: "Course not found" });
+ }
+
+ // Find the quiz lesson
+ let quizLesson = null;
+ for (const module of course.modules) {
+ const lesson = module.lessons.find(l => l._id.toString() === lessonId);
+ if (lesson) {
+ quizLesson = lesson;
+ break;
+ }
+ }
+
+ if (!quizLesson || quizLesson.type !== 'quiz') {
+ return res.status(404).json({ error: "Quiz not found" });
+ }
+
+ // Grade the quiz
+ let correctAnswers = 0;
+ const totalQuestions = quizLesson.questions.length;
+
+ quizLesson.questions.forEach((question, index) => {
+ const userAnswer = answers[index];
+
+ if (question.questionType === 'multiple-choice') {
+ if (userAnswer === question.correctAnswer) {
+ correctAnswers++;
+ }
+ } else if (question.questionType === 'short-answer') {
+ if (userAnswer?.toLowerCase().trim() === question.correctAnswer?.toLowerCase().trim()) {
+ correctAnswers++;
+ }
+ }
+ });
+
+ const score = Math.round((correctAnswers / totalQuestions) * 100);
+ const passed = score >= (quizLesson.passingScore || 70);
+
+ let progress = user.courseProgress.find(
+ p => p.courseId.toString() === courseId
+ );
+
+ if (!progress) {
+ progress = {
+ courseId,
+ completedLessons: [],
+ assignmentSubmissions: [],
+ quizResults: [],
+ lastAccessedAt: new Date()
+ };
+ user.courseProgress.push(progress);
+ }
+
+ // Save quiz result
+ const quizResult = {
+ lessonId,
+ score,
+ totalQuestions,
+ correctAnswers,
+ answers,
+ passed,
+ attemptedAt: new Date()
+ };
+
+ progress.quizResults.push(quizResult);
+
+ // Mark as complete if passed
+ if (passed && !progress.completedLessons.some(id => id.toString() === lessonId)) {
+ progress.completedLessons.push(lessonId);
+ }
+
+ progress.lastAccessedAt = new Date();
+
+ await user.save();
+
+ res.json({
+ message: passed ? "Quiz passed!" : "Quiz completed. Try again!",
+ result: quizResult
+ });
+
+ } catch (err) {
+ console.error("Error submitting quiz:", err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// ============================================
+// SUBMIT final test
+// ============================================
+router.post("/:courseId/progress/test/submit", protect, async (req, res) => {
+ try {
+ const { courseId } = req.params;
+ const { answers } = req.body;
+
+ const user = await User.findById(req.user._id);
+ const course = await Course.findById(courseId);
+
+ if (!course || !course.finalTest) {
+ return res.status(404).json({ error: "Final test not found" });
+ }
+
+ // Grade the test
+ let correctAnswers = 0;
+ const totalQuestions = course.finalTest.questions.length;
+
+ course.finalTest.questions.forEach((question, index) => {
+ const userAnswer = answers[index];
+ if (userAnswer === question.correctAnswer) {
+ correctAnswers++;
+ }
+ });
+
+ const score = Math.round((correctAnswers / totalQuestions) * 100);
+ const passed = score >= (course.finalTest.passingScore || 70);
+
+ let progress = user.courseProgress.find(
+ p => p.courseId.toString() === courseId
+ );
+
+ if (!progress) {
+ return res.status(400).json({ error: "No progress found for this course" });
+ }
+
+ // Update test results
+ progress.finalTestScore = score;
+ progress.finalTestPassed = passed;
+ progress.finalTestAttempts = (progress.finalTestAttempts || 0) + 1;
+ progress.lastAccessedAt = new Date();
+
+ // If passed, mark course as complete
+ if (passed) {
+ progress.isCompleted = true;
+ progress.completedAt = new Date();
+ progress.badgeEarned = true;
+ progress.progressPercentage = 100;
+
+ // Add to completedCourses if not already there
+ if (!user.completedCourses.includes(courseId)) {
+ user.completedCourses.push(courseId);
+ }
+ }
+
+ await user.save();
+
+ res.json({
+ message: passed ? "Congratulations! You've completed the course!" : "Test completed. Keep trying!",
+ score,
+ passed,
+ badgeEarned: passed,
+ badge: passed ? course.badge : null
+ });
+
+ } catch (err) {
+ console.error("Error submitting final test:", err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+module.exports = router;
\ No newline at end of file
diff --git a/backend/routes/courses.js b/backend/routes/courses.js
index a7a90d7..fb20685 100644
--- a/backend/routes/courses.js
+++ b/backend/routes/courses.js
@@ -1,5 +1,8 @@
const express = require("express");
const Course = require("../models/CourseModel");
+const User = require("../models/UserModel");
+const { protect } = require("../middleware/authMiddleware");
+const testCourses = require("../data/courses.json");
const router = express.Router();
@@ -8,6 +11,10 @@ const router = express.Router();
// -------------------------------------
router.get("/", async (req, res) => {
try {
+ if (process.env.NODE_ENV === "test" && !process.env.MONGO_URI) {
+ return res.json(testCourses);
+ }
+
const courses = await Course.find().sort({ createdAt: -1 });
res.json(courses);
} catch (err) {
@@ -21,6 +28,16 @@ router.get("/", async (req, res) => {
// -------------------------------------
router.get("/:id", async (req, res) => {
try {
+ if (process.env.NODE_ENV === "test" && !process.env.MONGO_URI) {
+ const course = testCourses.find((item) => item._id === req.params.id);
+
+ if (!course) {
+ return res.status(404).json({ message: "Course not found" });
+ }
+
+ return res.json(course);
+ }
+
const course = await Course.findById(req.params.id);
if (!course) {
@@ -37,12 +54,34 @@ router.get("/:id", async (req, res) => {
// -------------------------------------
// CREATE a course
// -------------------------------------
-router.post("/", async (req, res) => {
+router.post("/", protect, async (req, res) => {
try {
console.log("Incoming Create Course Request:");
console.log(JSON.stringify(req.body, null, 2));
- const course = new Course(req.body);
+ const instructor = await User.findById(req.user._id).select(
+ "firstName lastName email accountType"
+ );
+
+ if (!instructor) {
+ return res.status(404).json({ error: "Instructor not found" });
+ }
+
+ if (instructor.accountType !== "instructor") {
+ return res.status(403).json({ error: "Only instructors can create courses" });
+ }
+
+ const course = new Course({
+ ...req.body,
+ instructor: {
+ _id: instructor._id,
+ name: `${instructor.firstName} ${instructor.lastName}`.trim(),
+ email: instructor.email,
+ title: req.body.instructor?.title || "Instructor",
+ bio: req.body.instructor?.bio || "",
+ avatar: req.body.instructor?.avatar || "",
+ },
+ });
const saved = await course.save();
console.log("Saved Course:", saved);
@@ -58,18 +97,23 @@ router.post("/", async (req, res) => {
// -------------------------------------
// UPDATE a course
// -------------------------------------
-router.put("/:id", async (req, res) => {
+router.put("/:id", protect, async (req, res) => {
try {
+ const course = await Course.findById(req.params.id);
+ if (!course) {
+ return res.status(404).json({ error: "Course not found" });
+ }
+
+ if (course.instructor?._id?.toString() !== req.user._id.toString()) {
+ return res.status(403).json({ error: "You can only edit your own courses" });
+ }
+
const updated = await Course.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
- if (!updated) {
- return res.status(404).json({ error: "Course not found" });
- }
-
res.json(updated);
} catch (err) {
console.error("ERROR UPDATING COURSE:", err);
@@ -80,14 +124,18 @@ router.put("/:id", async (req, res) => {
// -------------------------------------
// DELETE a course
// -------------------------------------
-router.delete("/:id", async (req, res) => {
+router.delete("/:id", protect, async (req, res) => {
try {
- const deleted = await Course.findByIdAndDelete(req.params.id);
-
- if (!deleted) {
+ const course = await Course.findById(req.params.id);
+ if (!course) {
return res.status(404).json({ error: "Course not found" });
}
+ if (course.instructor?._id?.toString() !== req.user._id.toString()) {
+ return res.status(403).json({ error: "You can only delete your own courses" });
+ }
+
+ await Course.findByIdAndDelete(req.params.id);
res.json({ message: "Course deleted" });
} catch (err) {
console.error("ERROR DELETING COURSE:", err);
diff --git a/backend/routes/instructor.js b/backend/routes/instructor.js
new file mode 100644
index 0000000..247fb6a
--- /dev/null
+++ b/backend/routes/instructor.js
@@ -0,0 +1,363 @@
+const express = require('express');
+const router = express.Router();
+const Course = require('../models/CourseModel');
+const AssignmentSubmission = require('../models/AssignmentSubmission');
+const User = require('../models/UserModel');
+const { protect } = require('../middleware/authMiddleware');
+
+// Middleware to check if user is an instructor
+const isInstructor = async (req, res, next) => {
+ try {
+ // req.user is already set by the protect middleware
+ if (!req.user || req.user.accountType !== 'instructor') {
+ return res.status(403).json({ error: 'Access denied. Instructor account required.' });
+ }
+ next();
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+};
+
+// -------------------------------------
+// GET instructor dashboard stats
+// -------------------------------------
+router.get('/dashboard/stats', protect, isInstructor, async (req, res) => {
+ try {
+ const instructorId = req.user._id;
+
+ // Get all courses by this instructor
+ const courses = await Course.find({ 'instructor._id': instructorId });
+ const courseIds = courses.map(c => c._id);
+
+ // Count total students (unique enrollments across all courses)
+ const allEnrolledUsers = await User.find({
+ enrolledCourses: { $in: courseIds }
+ });
+ const totalStudents = allEnrolledUsers.length;
+
+ // Count pending submissions
+ const pendingCount = await AssignmentSubmission.countDocuments({
+ instructor: instructorId,
+ status: 'pending'
+ });
+
+ // Count total graded submissions
+ const gradedCount = await AssignmentSubmission.countDocuments({
+ instructor: instructorId,
+ status: 'graded'
+ });
+
+ // Calculate average grade across all graded submissions
+ const gradedSubmissions = await AssignmentSubmission.find({
+ instructor: instructorId,
+ status: 'graded'
+ });
+
+ let avgGrade = 0;
+ if (gradedSubmissions.length > 0) {
+ const totalPercentage = gradedSubmissions.reduce((sum, sub) => {
+ return sum + (sub.totalScore / sub.maxScore) * 100;
+ }, 0);
+ avgGrade = Math.round(totalPercentage / gradedSubmissions.length);
+ }
+
+ res.json({
+ totalCourses: courses.length,
+ totalStudents,
+ pendingSubmissions: pendingCount,
+ totalGraded: gradedCount,
+ averageGrade: avgGrade
+ });
+
+ } catch (err) {
+ console.error('Error fetching dashboard stats:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// -------------------------------------
+// GET all instructor's courses
+// -------------------------------------
+router.get('/courses', protect, isInstructor, async (req, res) => {
+ try {
+ const instructorId = req.user._id;
+
+ const courses = await Course.find({ 'instructor._id': instructorId })
+ .sort({ createdAt: -1 });
+
+ // For each course, get enrollment count and pending submissions
+ const coursesWithStats = await Promise.all(
+ courses.map(async (course) => {
+ const enrolledCount = await User.countDocuments({
+ enrolledCourses: course._id
+ });
+
+ const pendingCount = await AssignmentSubmission.countDocuments({
+ course: course._id,
+ status: 'pending'
+ });
+
+ return {
+ ...course.toObject(),
+ enrolledCount,
+ pendingSubmissions: pendingCount
+ };
+ })
+ );
+
+ res.json(coursesWithStats);
+
+ } catch (err) {
+ console.error('Error fetching instructor courses:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// -------------------------------------
+// GET pending submissions (FIXED - only one now)
+// -------------------------------------
+router.get('/submissions/pending', protect, isInstructor, async (req, res) => {
+ try {
+ console.log('Fetching pending submissions for instructor:', req.user._id);
+
+ const submissions = await AssignmentSubmission.find({
+ instructor: req.user._id,
+ status: 'pending'
+ })
+ .populate('student', 'firstName lastName email avatar')
+ .sort({ submittedAt: -1 });
+
+ console.log('Found pending submissions:', submissions.length);
+
+ res.json(submissions);
+
+ } catch (err) {
+ console.error('Error fetching pending submissions:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// -------------------------------------
+// GET all submissions (graded + pending)
+// -------------------------------------
+router.get('/submissions', protect, isInstructor, async (req, res) => {
+ try {
+ const instructorId = req.user._id;
+ const { courseId, status } = req.query;
+
+ const query = { instructor: instructorId };
+ if (courseId) query.course = courseId;
+ if (status) query.status = status;
+
+ const submissions = await AssignmentSubmission.find(query)
+ .sort({ submittedAt: -1 })
+ .populate('student', 'firstName lastName email avatar')
+ .populate('course', 'title thumbnail');
+
+ res.json(submissions);
+
+ } catch (err) {
+ console.error('Error fetching submissions:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// -------------------------------------
+// GET single submission for grading
+// -------------------------------------
+// GET single submission for grading
+router.get('/submissions/:id', protect, isInstructor, async (req, res) => {
+ try {
+ console.log('\n=== FETCHING SUBMISSION ===');
+ console.log('Submission ID:', req.params.id);
+
+ const submission = await AssignmentSubmission.findById(req.params.id)
+ .populate('student', 'firstName lastName email avatar')
+ .populate('course', 'title');
+
+ console.log('Submission found:', submission ? 'YES' : 'NO');
+
+ if (!submission) {
+ console.log('❌ Submission not found');
+ return res.status(404).json({ error: 'Submission not found' });
+ }
+
+ console.log('Submission data:', {
+ student: submission.student,
+ course: submission.course,
+ assignmentType: submission.assignmentType,
+ hasAnswers: !!submission.answers,
+ hasPartAnswers: !!submission.partAnswers
+ });
+
+ // Verify this submission belongs to this instructor
+ if (submission.instructor.toString() !== req.user._id.toString()) {
+ console.log('❌ Access denied - wrong instructor');
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ console.log('✅ Returning submission to frontend');
+ res.json(submission);
+
+ } catch (err) {
+ console.error('\n❌ ERROR FETCHING SUBMISSION:');
+ console.error('Message:', err.message);
+ console.error('Stack:', err.stack);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// -------------------------------------
+// POST grade submission
+// -------------------------------------
+router.post('/submissions/:id/grade', protect, isInstructor, async (req, res) => {
+ try {
+ const { grades, overallFeedback } = req.body;
+
+ const submission = await AssignmentSubmission.findById(req.params.id);
+
+ if (!submission) {
+ return res.status(404).json({ error: 'Submission not found' });
+ }
+
+ // Verify this submission belongs to this instructor
+ if (submission.instructor.toString() !== req.user._id.toString()) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ // Calculate total score
+ let totalScore = 0;
+ Object.values(grades).forEach(grade => {
+ totalScore += grade.points;
+ });
+
+ // Calculate if passed (based on percentage)
+ const percentage = (totalScore / submission.maxScore) * 100;
+ const passed = percentage >= submission.passingScore;
+
+ // Update submission
+ submission.grades = grades;
+ submission.totalScore = totalScore;
+ submission.passed = passed;
+ submission.overallFeedback = overallFeedback;
+ submission.status = 'graded';
+ submission.gradedAt = new Date();
+
+ await submission.save();
+
+ res.json({
+ message: 'Submission graded successfully',
+ submission
+ });
+
+ } catch (err) {
+ console.error('Error grading submission:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// -------------------------------------
+// GET students for a specific course
+// -------------------------------------
+router.get('/courses/:courseId/students', protect, isInstructor, async (req, res) => {
+ try {
+ const { courseId } = req.params;
+
+ // Verify the course belongs to this instructor
+ const course = await Course.findById(courseId);
+ if (!course || course.instructor._id.toString() !== req.user._id.toString()) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ // Get all enrolled students
+ const students = await User.find({
+ enrolledCourses: courseId
+ }).select('firstName lastName username email avatar enrolledAt');
+
+ // For each student, get their progress
+ const studentsWithProgress = await Promise.all(
+ students.map(async (student) => {
+ // Get their submissions for this course
+ const submissions = await AssignmentSubmission.find({
+ student: student._id,
+ course: courseId
+ });
+
+ const totalSubmissions = submissions.length;
+ const gradedSubmissions = submissions.filter(s => s.status === 'graded');
+ const avgGrade = gradedSubmissions.length > 0
+ ? Math.round(
+ gradedSubmissions.reduce((sum, s) => sum + (s.totalScore / s.maxScore) * 100, 0) /
+ gradedSubmissions.length
+ )
+ : 0;
+
+ return {
+ ...student.toObject(),
+ name: student.firstName && student.lastName
+ ? `${student.firstName} ${student.lastName}`
+ : student.username,
+ totalSubmissions,
+ gradedSubmissions: gradedSubmissions.length,
+ averageGrade: avgGrade
+ };
+ })
+ );
+
+ res.json(studentsWithProgress);
+
+ } catch (err) {
+ console.error('Error fetching students:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// -------------------------------------
+// GET course stats
+// -------------------------------------
+router.get('/courses/:courseId/stats', protect, isInstructor, async (req, res) => {
+ try {
+ const { courseId } = req.params;
+
+ const course = await Course.findById(courseId);
+ if (!course || course.instructor._id.toString() !== req.user._id.toString()) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const enrolledCount = await User.countDocuments({
+ enrolledCourses: courseId
+ });
+
+ const submissions = await AssignmentSubmission.find({
+ course: courseId
+ });
+
+ const pendingCount = submissions.filter(s => s.status === 'pending').length;
+ const gradedCount = submissions.filter(s => s.status === 'graded').length;
+
+ const gradedSubmissions = submissions.filter(s => s.status === 'graded');
+ const avgGrade = gradedSubmissions.length > 0
+ ? Math.round(
+ gradedSubmissions.reduce((sum, s) => sum + (s.totalScore / s.maxScore) * 100, 0) /
+ gradedSubmissions.length
+ )
+ : 0;
+
+ res.json({
+ enrolledStudents: enrolledCount,
+ totalSubmissions: submissions.length,
+ pendingSubmissions: pendingCount,
+ gradedSubmissions: gradedCount,
+ averageGrade: avgGrade,
+ passRate: gradedSubmissions.length > 0
+ ? Math.round((gradedSubmissions.filter(s => s.passed).length / gradedSubmissions.length) * 100)
+ : 0
+ });
+
+ } catch (err) {
+ console.error('Error fetching course stats:', err);
+ res.status(500).json({ error: err.message });
+ }
+});
+
+module.exports = router;
\ No newline at end of file
diff --git a/backend/routes/learningRoutes.js b/backend/routes/learningRoutes.js
new file mode 100644
index 0000000..c344cb9
--- /dev/null
+++ b/backend/routes/learningRoutes.js
@@ -0,0 +1,100 @@
+const express = require("express");
+const mongoose = require("mongoose");
+const User = require("../models/UserModel");
+const { protect } = require("../middleware/authMiddleware");
+
+const router = express.Router();
+
+/**
+ * POST /api/learning/track
+ * Body: { courseId, elapsedSec }
+ * Requires auth cookie (protect middleware)
+ */
+router.post("/track", protect, async (req, res) => {
+ try {
+ const userId = req.user._id;
+ const { courseId, elapsedSec } = req.body;
+
+ const sec = Number(elapsedSec);
+ if (!Number.isFinite(sec) || sec <= 0) {
+ return res.status(400).json({ message: "elapsedSec must be a positive number" });
+ }
+
+ // Always update overall time
+ const baseUpdate = {
+ $inc: { "learning.totalSeconds": sec },
+ $set: { "learning.updatedAt": new Date() }
+ };
+
+ // If no courseId provided, just update overall
+ if (!courseId) {
+ await User.updateOne({ _id: userId }, baseUpdate);
+ return res.json({ ok: true });
+ }
+
+ if (!mongoose.Types.ObjectId.isValid(courseId)) {
+ return res.status(400).json({ message: "Invalid courseId" });
+ }
+
+ // 1) Try to increment existing byCourse entry (positional update)
+ const updated = await User.updateOne(
+ { _id: userId, "learning.byCourse.courseId": courseId },
+ {
+ ...baseUpdate,
+ $inc: {
+ ...baseUpdate.$inc,
+ "learning.byCourse.$.totalSeconds": sec
+ },
+ $set: {
+ ...baseUpdate.$set,
+ "learning.byCourse.$.lastTrackedAt": new Date()
+ }
+ }
+ );
+
+ // 2) If no existing entry, push a new one
+ if (updated.matchedCount === 0) {
+ await User.updateOne(
+ { _id: userId },
+ {
+ ...baseUpdate,
+ $push: {
+ "learning.byCourse": {
+ courseId,
+ totalSeconds: sec,
+ lastTrackedAt: new Date()
+ }
+ }
+ }
+ );
+ }
+
+ res.json({ ok: true });
+ } catch (err) {
+ console.error("learning track error:", err);
+ res.status(500).json({ message: "Server error tracking learning time" });
+ }
+});
+
+/**
+ * GET /api/learning/summary
+ * Returns totals for dashboard
+ */
+router.get("/summary", protect, async (req, res) => {
+ try {
+ const user = await User.findById(req.user._id).select("learning").lean();
+
+ res.json({
+ totalSeconds: user?.learning?.totalSeconds ?? 0,
+ byCourse: (user?.learning?.byCourse ?? []).map(x => ({
+ courseId: x.courseId,
+ totalSeconds: x.totalSeconds
+ }))
+ });
+ } catch (err) {
+ console.error("learning summary error:", err);
+ res.status(500).json({ message: "Server error fetching learning summary" });
+ }
+});
+
+module.exports = router;
\ No newline at end of file
diff --git a/backend/routes/payments.js b/backend/routes/payments.js
index 84a6a1f..54d33cd 100644
--- a/backend/routes/payments.js
+++ b/backend/routes/payments.js
@@ -1,50 +1,59 @@
const express = require("express");
const Stripe = require("stripe");
const Course = require("../models/CourseModel");
+const User = require("../models/UserModel");
+const { protect } = require("../middleware/authMiddleware");
const router = express.Router();
+const stripe = process.env.STRIPE_SECRET_KEY
+ ? new Stripe(process.env.STRIPE_SECRET_KEY)
+ : null;
-if (!process.env.STRIPE_SECRET_KEY) {
+if (!stripe) {
console.warn("Stripe payments disabled: STRIPE_SECRET_KEY not set");
+}
- router.post("/create-payment-intent", (req, res) => {
- return res.status(501).json({
- error: "Payments are disabled in this environment",
- });
- });
-} else {
- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
+router.post("/create-payment-intent", protect, async (req, res) => {
+ const { courseId } = req.body;
- router.post("/create-payment-intent", async (req, res) => {
- const { courseId, userId } = req.body;
+ if (!courseId) {
+ return res.status(400).json({ error: "courseId required" });
+ }
- if (!courseId || !userId) {
- return res.status(400).json({ error: "courseId and userId required" });
+ try {
+ const course = await Course.findById(courseId);
+ if (!course) {
+ return res.status(404).json({ error: "Course not found" });
}
- try {
- const course = await Course.findById(courseId);
- if (!course) {
- return res.status(404).json({ error: "Course not found" });
- }
-
- if (course.isFree) {
- return res.json({ free: true });
- }
-
- const paymentIntent = await stripe.paymentIntents.create({
- amount: Math.round(course.priceAmount * 100),
- currency: "usd",
- automatic_payment_methods: { enabled: true },
- metadata: { courseId, userId },
+ if (course.isFree) {
+ await User.findByIdAndUpdate(req.user._id, {
+ $addToSet: { enrolledCourses: course._id },
});
+ return res.json({ free: true });
+ }
- res.json({ clientSecret: paymentIntent.client_secret });
- } catch (err) {
- console.error(err);
- res.status(500).json({ error: "Payment failed" });
+ if (!stripe) {
+ return res.status(501).json({
+ error: "Payments are disabled in this environment",
+ });
}
- });
-}
+
+ const paymentIntent = await stripe.paymentIntents.create({
+ amount: Math.round(course.priceAmount * 100),
+ currency: "usd",
+ automatic_payment_methods: { enabled: true },
+ metadata: {
+ courseId: course._id.toString(),
+ userId: req.user._id.toString(),
+ },
+ });
+
+ res.json({ clientSecret: paymentIntent.client_secret });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "Payment failed" });
+ }
+});
module.exports = router;
diff --git a/backend/routes/seminars.js b/backend/routes/seminars.js
index d5019f1..c5910d7 100644
--- a/backend/routes/seminars.js
+++ b/backend/routes/seminars.js
@@ -1,7 +1,51 @@
const express = require("express");
+const jwt = require("jsonwebtoken");
const Seminar = require("../models/SeminarModel");
+const { protect } = require("../middleware/authMiddleware");
const router = express.Router();
+const LIVE_BUFFER_MS = 30 * 60 * 1000;
+
+const getSeminarStatus = (seminar, now = Date.now()) => {
+ const schedule = seminar?.schedule || {};
+ const startAt = schedule.startAt ? new Date(schedule.startAt).getTime() : NaN;
+ const endAt = schedule.endAt ? new Date(schedule.endAt).getTime() : NaN;
+
+ if (Number.isNaN(startAt) || Number.isNaN(endAt) || endAt <= startAt) {
+ return "Past";
+ }
+
+ if (now < startAt - LIVE_BUFFER_MS) return "Future";
+ if (now > endAt + LIVE_BUFFER_MS) return "Past";
+ return "Live Now";
+};
+
+const normalizeSchedule = (schedule = {}) => {
+ const normalized = { ...schedule };
+
+ if (schedule.startAt) {
+ const start = new Date(schedule.startAt);
+ if (Number.isNaN(start.getTime())) {
+ throw new Error("Invalid schedule.startAt");
+ }
+ normalized.startAt = start;
+ }
+
+ if (schedule.endAt) {
+ const end = new Date(schedule.endAt);
+ if (Number.isNaN(end.getTime())) {
+ throw new Error("Invalid schedule.endAt");
+ }
+ normalized.endAt = end;
+ }
+
+ if (normalized.startAt && normalized.endAt && normalized.endAt <= normalized.startAt) {
+ throw new Error("schedule.endAt must be after schedule.startAt");
+ }
+
+ return normalized;
+};
+
// Get all seminars
router.get("/", async (req, res) => {
try {
@@ -17,7 +61,12 @@ router.post("/", async (req, res) => {
try {
console.log("Incoming Seminar:", req.body);
- const seminar = new Seminar(req.body);
+ const payload = {
+ ...req.body,
+ ...(req.body.schedule ? { schedule: normalizeSchedule(req.body.schedule) } : {})
+ };
+
+ const seminar = new Seminar(payload);
const saved = await seminar.save();
res.status(201).json(saved);
@@ -38,12 +87,86 @@ router.get("/:id", async (req, res) => {
}
});
+// Zoom Meeting SDK signature for seminar join
+router.get("/:id/zoom-signature", protect, async (req, res) => {
+ try {
+ const sdkKey = process.env.ZOOM_MEETING_SDK_KEY;
+ const sdkSecret = process.env.ZOOM_MEETING_SDK_SECRET;
+
+ if (!sdkKey || !sdkSecret) {
+ return res.status(500).json({ message: "Zoom SDK credentials are not configured" });
+ }
+
+ const seminar = await Seminar.findById(req.params.id);
+ if (!seminar) {
+ return res.status(404).json({ message: "Seminar not found" });
+ }
+
+ const meetingNumber = seminar.schedule?.zoomMeetingId;
+ if (!meetingNumber) {
+ return res.status(400).json({ message: "Zoom meeting ID is missing for this seminar" });
+ }
+
+ const requestedMeetingNumber = req.query.meetingNumber;
+ if (requestedMeetingNumber && String(requestedMeetingNumber) !== String(meetingNumber)) {
+ return res.status(400).json({ message: "Meeting number does not match seminar" });
+ }
+
+ const status = getSeminarStatus(seminar);
+ if (status !== "Live Now") {
+ return res.status(403).json({ message: "Zoom join is only available while this seminar is live" });
+ }
+
+ const role = Number(req.query.role ?? 0);
+ if (![0, 1].includes(role)) {
+ return res.status(400).json({ message: "Invalid role. Use 0 (attendee) or 1 (host)" });
+ }
+
+ const videoWebRtcMode = req.query.videoWebRtcMode === undefined
+ ? undefined
+ : Number(req.query.videoWebRtcMode);
+
+ if (videoWebRtcMode !== undefined && ![0, 1].includes(videoWebRtcMode)) {
+ return res.status(400).json({ message: "Invalid videoWebRtcMode. Use 0 or 1" });
+ }
+
+ const iat = Math.floor(Date.now() / 1000);
+ const exp = iat + 60 * 60 * 2;
+
+ const payload = {
+ appKey: sdkKey,
+ sdkKey,
+ mn: String(meetingNumber),
+ role,
+ iat,
+ exp,
+ tokenExp: exp
+ };
+
+ if (videoWebRtcMode !== undefined) {
+ payload.video_webrtc_mode = videoWebRtcMode;
+ }
+
+ const signature = jwt.sign(payload, sdkSecret, { algorithm: "HS256" });
+
+ res.json({ signature, sdkKey });
+ } catch (error) {
+ console.error("Zoom signature error:", error);
+ res.status(500).json({ message: "Error generating Zoom signature" });
+ }
+});
+
// Update seminar
router.put("/:id", async (req, res) => {
try {
+ const payload = {
+ ...req.body,
+ ...(req.body.schedule ? { schedule: normalizeSchedule(req.body.schedule) } : {})
+ };
+
const updated = await Seminar.findByIdAndUpdate(
req.params.id,
- req.body,
+ payload,
{ new: true, runValidators: true }
);
res.json(updated);
diff --git a/backend/routes/upload.js b/backend/routes/upload.js
new file mode 100644
index 0000000..276d696
--- /dev/null
+++ b/backend/routes/upload.js
@@ -0,0 +1,87 @@
+const express = require("express");
+const multer = require("multer");
+const cloudinary = require("../config/cloudinary");
+
+const router = express.Router();
+
+const hasCloudinaryConfig = Boolean(
+ process.env.CLOUDINARY_CLOUD_NAME &&
+ process.env.CLOUDINARY_API_KEY &&
+ process.env.CLOUDINARY_API_SECRET
+);
+
+const upload = multer({
+ storage: multer.memoryStorage(),
+ limits: {
+ fileSize: 5 * 1024 * 1024,
+ },
+ fileFilter: (_req, file, cb) => {
+ if (!file.mimetype.startsWith("image/")) {
+ return cb(new Error("Only image files are allowed"), false);
+ }
+ return cb(null, true);
+ },
+});
+
+router.post("/image", upload.single("image"), async (req, res) => {
+ try {
+ if (!hasCloudinaryConfig) {
+ return res.status(501).json({ error: "Cloudinary upload is not configured" });
+ }
+
+ if (!req.file) {
+ return res.status(400).json({ error: "No file uploaded" });
+ }
+
+ const result = await new Promise((resolve, reject) => {
+ const uploadStream = cloudinary.uploader.upload_stream(
+ {
+ folder: "unifreelancer/courses",
+ resource_type: "image",
+ transformation: [
+ { width: 1200, height: 675, crop: "limit" },
+ { quality: "auto" },
+ { fetch_format: "auto" },
+ ],
+ },
+ (error, uploadResult) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve(uploadResult);
+ }
+ );
+
+ uploadStream.end(req.file.buffer);
+ });
+
+ res.json({
+ success: true,
+ url: result.secure_url,
+ publicId: result.public_id,
+ });
+ } catch (error) {
+ console.error("UPLOAD ROUTE ERROR:", error);
+ res.status(500).json({
+ error: error.message || "Failed to upload image",
+ });
+ }
+});
+
+router.delete("/image/:publicId", async (req, res) => {
+ try {
+ if (!hasCloudinaryConfig) {
+ return res.status(501).json({ error: "Cloudinary upload is not configured" });
+ }
+
+ const publicId = req.params.publicId.replace(/_/g, "/");
+ await cloudinary.uploader.destroy(publicId);
+ res.json({ success: true, message: "Image deleted" });
+ } catch (error) {
+ console.error("Delete error:", error);
+ res.status(500).json({ error: "Failed to delete image" });
+ }
+});
+
+module.exports = router;
diff --git a/backend/routes/users.js b/backend/routes/users.js
index 077dad8..ee72ff2 100644
--- a/backend/routes/users.js
+++ b/backend/routes/users.js
@@ -24,7 +24,7 @@ router.post("/register", async (req, res) => {
username,
email,
password,
- role,
+ accountType,
} = req.body;
// Basic required fields check
@@ -34,6 +34,10 @@ router.post("/register", async (req, res) => {
.json({ message: "Missing required fields for registration" });
}
+ if (accountType && !["student", "instructor"].includes(accountType)) {
+ return res.status(400).json({ message: "Invalid account type" });
+ }
+
// Check if email or username already exists
const existingUser = await User.findOne({
$or: [{ email }, { username }],
@@ -46,8 +50,8 @@ router.post("/register", async (req, res) => {
lastName,
username,
email,
- password, // <-- will be hashed by the pre-save hook
- role,
+ password,
+ accountType: accountType || "student",
});
await user.save();
@@ -58,7 +62,7 @@ router.post("/register", async (req, res) => {
res.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Use secure cookies in production
- sameSite: 'strict', // Prevent CSRF
+ sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});
@@ -105,7 +109,7 @@ router.post("/login", async (req, res) => {
res.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
- sameSite: 'strict',
+ sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
@@ -267,6 +271,25 @@ router.delete("/tutorials/:tutorialId/complete", protect, async (req, res) => {
// -------------------------------
// Enroll in a course
// -------------------------------
+router.post("/enroll/:courseId", protect, async (req, res) => {
+ try {
+ const user = await User.findById(req.user._id);
+ if (!user) return res.status(404).json({ message: "User not found" });
+
+ if (!user.enrolledCourses.includes(req.params.courseId)) {
+ user.enrolledCourses.push(req.params.courseId);
+ await user.save();
+ }
+
+ res.json({
+ message: "Course enrolled successfully",
+ enrolledCourses: user.enrolledCourses,
+ });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
router.post("/:id/enroll-course/:courseId", async (req, res) => {
try {
const user = await User.findById(req.params.id);
diff --git a/backend/server.js b/backend/server.js
index 5474604..f517968 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -1,6 +1,7 @@
require("dotenv").config();
const express = require("express");
+const path = require("path");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const connectDB = require("./config/db");
@@ -11,7 +12,9 @@ const PORT = process.env.PORT || 5000;
// ------------------------------
// CONNECT TO MONGO
// ------------------------------
-connectDB().catch((err) => console.error("MongoDB connection error:", err));
+if (process.env.NODE_ENV !== "test") {
+ connectDB().catch((err) => console.error("MongoDB connection error:", err));
+}
// ------------------------------
// STRIPE WEBHOOK (MUST BE BEFORE express.json())
@@ -27,12 +30,18 @@ app.use(
// ------------------------------
// NORMAL MIDDLEWARE
// ------------------------------
-app.use(cors({
- origin: process.env.FRONTEND_URL || "http://localhost:3000",
- credentials: true
-}));
+if (process.env.NODE_ENV !== "production") {
+ app.use(cors({
+ origin: process.env.FRONTEND_URL || "http://localhost:3000",
+ credentials: true
+ }));
+}
app.use(express.json());
app.use(cookieParser());
+app.use((req, res, next) => {
+ res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
+ next();
+});
// ------------------------------
// ROUTES
@@ -44,6 +53,11 @@ const seminarsRoutes = require("./routes/seminars");
const podcastsRoutes = require("./routes/podcasts");
const userRoutes = require("./routes/users");
const paymentRoutes = require("./routes/payments");
+const courseProgressRoutes = require("./routes/courseProgress");
+const uploadRoutes = require("./routes/upload");
+const instructorRoutes = require("./routes/instructor");
+const assignmentRoutes = require("./routes/assignments");
+const learningRoutes = require("./routes/learningRoutes");
app.use("/api/academy", academyRoutes);
app.use("/api/academy/courses", coursesRoutes);
@@ -52,6 +66,11 @@ app.use("/api/academy/seminars", seminarsRoutes);
app.use("/api/academy/podcasts", podcastsRoutes);
app.use("/api/users", userRoutes);
app.use("/api/payments", paymentRoutes);
+app.use("/api/courses", courseProgressRoutes);
+app.use("/api/upload", uploadRoutes);
+app.use("/api/instructor", instructorRoutes);
+app.use("/api/assignments", assignmentRoutes);
+app.use("/api/learning", learningRoutes);
// ------------------------------
// HEALTH CHECK
@@ -63,6 +82,21 @@ app.get("/api/health", (req, res) => {
});
});
+// ------------------------------
+// SERVE FRONTEND IN PRODUCTION
+// ------------------------------
+if (process.env.NODE_ENV === "production") {
+ const frontendBuildPath = path.join(__dirname, "..", "frontend", "build");
+ app.use(express.static(frontendBuildPath));
+
+ app.use((req, res, next) => {
+ if (req.path.startsWith("/api")) {
+ return next();
+ }
+ res.sendFile(path.join(frontendBuildPath, "index.html"));
+ });
+}
+
// ------------------------------
// ERROR HANDLER
// ------------------------------
@@ -79,7 +113,8 @@ let server;
if (process.env.NODE_ENV !== "test") {
server = app.listen(PORT, () => {
console.log(`Backend running on port ${PORT}`);
+ console.log(`Environment: ${process.env.NODE_ENV || "development"}`);
});
}
-module.exports = { app, server };
\ No newline at end of file
+module.exports = { app, server };
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 12b10bc..3e4b341 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.1",
+ "@zoom/meetingsdk": "^5.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
@@ -3523,6 +3524,18 @@
"@types/node": "*"
}
},
+ "node_modules/@types/hoist-non-react-statics": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
+ "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
+ "license": "MIT",
+ "dependencies": {
+ "hoist-non-react-statics": "^3.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@@ -3616,6 +3629,12 @@
"integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==",
"license": "MIT"
},
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "license": "MIT"
+ },
"node_modules/@types/q": {
"version": "1.5.8",
"resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.8.tgz",
@@ -3634,6 +3653,17 @@
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"license": "MIT"
},
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
"node_modules/@types/resolve": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@@ -3715,6 +3745,12 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
+ "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==",
+ "license": "MIT"
+ },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -4134,6 +4170,101 @@
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"license": "Apache-2.0"
},
+ "node_modules/@zoom/meetingsdk": {
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/@zoom/meetingsdk/-/meetingsdk-5.1.4.tgz",
+ "integrity": "sha512-76UG03aIHmWFWwiE801EECc//pgJv1rUYk7msf5xlxwokT1Kr3YaEWmZC7ZNiPCkOW2EzeYZyff0K0x3BZAfZg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.23",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "react-redux": "8.1.2",
+ "redux": "4.2.1",
+ "redux-thunk": "2.4.2"
+ },
+ "peerDependencies": {
+ "lodash": "^4.17.23",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "react-redux": "8.1.2",
+ "redux": "4.2.1",
+ "redux-thunk": "2.4.2"
+ }
+ },
+ "node_modules/@zoom/meetingsdk/node_modules/react": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
+ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@zoom/meetingsdk/node_modules/react-dom": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+ "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.0"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0"
+ }
+ },
+ "node_modules/@zoom/meetingsdk/node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
+ "node_modules/@zoom/meetingsdk/node_modules/react-redux": {
+ "version": "8.1.2",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.2.tgz",
+ "integrity": "sha512-xJKYI189VwfsFc4CJvHqHlDrzyFTY/3vZACbE+rr/zQ34Xx1wQfB4OTOSeOSNrF6BDVe8OOdxIrAnMGXA3ggfw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.1",
+ "@types/hoist-non-react-statics": "^3.3.1",
+ "@types/use-sync-external-store": "^0.0.3",
+ "hoist-non-react-statics": "^3.3.2",
+ "react-is": "^18.0.0",
+ "use-sync-external-store": "^1.0.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.8 || ^17.0 || ^18.0",
+ "@types/react-dom": "^16.8 || ^17.0 || ^18.0",
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0",
+ "react-native": ">=0.59",
+ "redux": "^4 || ^5.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -6209,6 +6340,12 @@
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
"license": "MIT"
},
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -8632,6 +8769,21 @@
"he": "bin/he"
}
},
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
+ "node_modules/hoist-non-react-statics/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/hoopy": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz",
@@ -10937,9 +11089,9 @@
}
},
"node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
@@ -13755,6 +13907,25 @@
"node": ">=6.0.0"
}
},
+ "node_modules/redux": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
+ "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/runtime": "^7.9.2"
+ }
+ },
+ "node_modules/redux-thunk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz",
+ "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^4"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -16257,6 +16428,15 @@
"requires-port": "^1.0.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 6544aa4..f7f2fda 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -2,10 +2,12 @@
"name": "unifreelancer-frontend",
"version": "1.0.0",
"private": true,
+ "proxy": "http://localhost:5000",
"description": "UniFreelancer Academy Frontend",
"dependencies": {
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.1",
+ "@zoom/meetingsdk": "^5.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index cdf7e74..6b13918 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -6,10 +6,15 @@ import LearningHub from './pages/Academy/LearningHub/LearningHub';
import CreateContent from './pages/Academy/CreateContent/CreateContent';
import CreateCourse from './pages/Academy/Courses/CreateCourse';
import CourseDetail from './pages/Academy/Courses/CourseDetail';
+import MyCourses from './pages/Academy/Courses/MyCourses';
+import CourseLearning from './pages/Academy/Courses/CourseLearning';
import CreateSeminar from './pages/Academy/Seminars/CreateSeminar';
-import SeminarDetail from './pages/Academy/Seminars/SeminarSingle';
+import SeminarDetails from './pages/Academy/Seminars/SeminarDetails';
+import SeminarZoomPage from './pages/Academy/Seminars/SeminarZoomPage';
import CreateTutorial from './pages/Academy/Tutorials/CreateTutorial';
import TutorialDetail from './pages/Academy/Tutorials/TutorialDetail';
+import InstructorDashboard from './pages/Instructor/InstructorDashboard';
+import GradingInterface from './pages/Instructor/GradingInterface';
import Login from './pages/Auth/Login';
import Signup from './pages/Auth/Signup';
import Profile from './pages/Auth/Profile';
@@ -21,8 +26,7 @@ function App() {
useEffect(() => {
const fetchUser = async () => {
try {
- // eslint-disable-next-line no-undef
- const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:5000';
+ const apiUrl = process.env.REACT_APP_API_URL || '';
const response = await fetch(`${apiUrl}/api/users/me`, {
credentials: 'include',
});
@@ -51,19 +55,25 @@ function App() {
} />
} />
} />
+ } />
} />
} />
} />
} />
} />
+ } />
} />
+ } />
} />
} />
} />
- } />
+ } />
+ } />
} />
} />
} />
+ } />
+ } />
@@ -95,6 +105,11 @@ NavLink.propTypes = {
function Header({ user }) {
const location = useLocation();
+ const isSeminarJoinRoute = /^\/academy\/seminars\/[^/]+\/join$/.test(location.pathname);
+
+ if (isSeminarJoinRoute) {
+ return null;
+ }
const isActive = (path) => {
return location.pathname === path;
@@ -119,6 +134,9 @@ function Header({ user }) {
UF Social
About Us
Inbox
+ {user?.accountType === 'instructor' && (
+ Dashboard
+ )}
{user ? (
@@ -146,7 +164,8 @@ Header.propTypes = {
lastName: PropTypes.string,
username: PropTypes.string,
email: PropTypes.string,
- _id: PropTypes.string
+ _id: PropTypes.string,
+ accountType: PropTypes.string,
})
};
diff --git a/frontend/src/components/Courses/CourseCard.css b/frontend/src/components/Courses/CourseCard.css
new file mode 100644
index 0000000..6059bb4
--- /dev/null
+++ b/frontend/src/components/Courses/CourseCard.css
@@ -0,0 +1,149 @@
+.course-card {
+ background: #ffffff;
+ border-radius: 12px;
+ overflow: hidden;
+ cursor: pointer;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+ transition: all 0.3s ease;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.course-card:hover {
+ transform: translateY(-8px);
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
+}
+
+.course-card-image {
+ height: 180px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+}
+
+.course-card-image img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.course-card-placeholder {
+ font-size: 4rem;
+ opacity: 0.9;
+}
+
+.course-card-body {
+ padding: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.course-card-body h3 {
+ margin: 0 0 0.75rem;
+ font-size: 1.25rem;
+ color: #1a1a1a;
+ font-weight: 600;
+ line-height: 1.3;
+ max-height: 2.6em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.course-card-description {
+ font-size: 0.9rem;
+ color: #666;
+ line-height: 1.5;
+ margin-bottom: 1rem;
+ flex-grow: 1;
+ max-height: 4.5em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.course-card-meta {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 1.25rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid #e5e5e5;
+ flex-wrap: wrap;
+}
+
+.course-meta-item {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.85rem;
+ color: #555;
+}
+
+.meta-icon {
+ font-size: 1rem;
+}
+
+.course-card-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+}
+
+.course-price {
+ font-weight: 700;
+ font-size: 1.5rem;
+}
+
+.price-free {
+ color: #10b981;
+}
+
+.price-paid {
+ color: #3b82f6;
+}
+
+.view-details-btn {
+ background: #ff6b35;
+ color: white;
+ border: none;
+ padding: 0.65rem 1.25rem;
+ border-radius: 6px;
+ font-weight: 600;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+}
+
+.view-details-btn:hover {
+ background: #ff5722;
+ transform: translateX(2px);
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+ .course-card-body {
+ padding: 1.25rem;
+ }
+
+ .course-card-body h3 {
+ font-size: 1.1rem;
+ }
+
+ .course-card-meta {
+ gap: 0.75rem;
+ }
+
+ .course-meta-item {
+ font-size: 0.8rem;
+ }
+
+ .view-details-btn {
+ padding: 0.5rem 1rem;
+ font-size: 0.85rem;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/components/Courses/CourseCard.jsx b/frontend/src/components/Courses/CourseCard.jsx
new file mode 100644
index 0000000..6ffa5ac
--- /dev/null
+++ b/frontend/src/components/Courses/CourseCard.jsx
@@ -0,0 +1,72 @@
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import "./CourseCard.css";
+
+const CourseCard = ({ course }) => {
+ const navigate = useNavigate();
+ const priceAmount = course?.pricing?.amount ?? course?.priceAmount ?? 0;
+ const isFree = Boolean(course?.isFree || course?.isLiteVersion || priceAmount === 0);
+
+ return (
+
navigate(`/academy/courses/${course._id}`)}
+ >
+
+ {course.thumbnail ? (
+
+ ) : (
+
Course
+ )}
+
+
+
+
{course.title}
+
+
+ {course.description?.slice(0, 100)}
+ {course.description?.length > 100 ? "..." : ""}
+
+
+
+
+ Time
+ {course.duration || "Self-paced"}
+
+
+
+ Topic
+ {course.category || "General"}
+
+
+
+ Level
+ {course.difficulty || "Beginner"}
+
+
+
+
+
+ {isFree ? (
+ Free
+ ) : (
+ ${priceAmount}
+ )}
+
+
+
{
+ event.stopPropagation();
+ navigate(`/academy/courses/${course._id}/learn`);
+ }}
+ >
+ Go to Course
+
+
+
+
+ );
+};
+
+export default CourseCard;
diff --git a/frontend/src/components/ImageUpload.css b/frontend/src/components/ImageUpload.css
new file mode 100644
index 0000000..4d04a97
--- /dev/null
+++ b/frontend/src/components/ImageUpload.css
@@ -0,0 +1,138 @@
+.image-upload-container {
+ margin-bottom: 1.5rem;
+}
+
+.image-upload-label {
+ display: block;
+ font-weight: 600;
+ color: #333;
+ margin-bottom: 0.75rem;
+}
+
+.upload-options {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.upload-option {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.upload-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ background: #4F46E5;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+ width: fit-content;
+}
+
+.upload-button:hover:not(:disabled) {
+ background: #4338ca;
+}
+
+.upload-button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.upload-hint {
+ font-size: 0.875rem;
+ color: #666;
+}
+
+.upload-divider {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin: 0.5rem 0;
+}
+
+.upload-divider::before,
+.upload-divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: #e5e7eb;
+}
+
+.upload-divider span {
+ color: #999;
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+.url-input {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-family: inherit;
+ transition: border-color 0.2s;
+}
+
+.url-input:focus {
+ outline: none;
+ border-color: #4F46E5;
+}
+
+.url-input:disabled {
+ background: #f9fafb;
+ cursor: not-allowed;
+}
+
+.image-preview {
+ margin-top: 1rem;
+ position: relative;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 2px solid #e5e7eb;
+ max-width: 400px;
+}
+
+.image-preview img {
+ width: 100%;
+ height: auto;
+ display: block;
+}
+
+.remove-preview {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ background: rgba(239, 68, 68, 0.9);
+ color: white;
+ border: none;
+ padding: 0.5rem 1rem;
+ border-radius: 6px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+ font-size: 0.875rem;
+}
+
+.remove-preview:hover:not(:disabled) {
+ background: rgba(220, 38, 38, 0.9);
+}
+
+.remove-preview:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+@media (max-width: 768px) {
+ .image-preview {
+ max-width: 100%;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/components/ImageUpload.jsx b/frontend/src/components/ImageUpload.jsx
new file mode 100644
index 0000000..cea1aa0
--- /dev/null
+++ b/frontend/src/components/ImageUpload.jsx
@@ -0,0 +1,120 @@
+import React, { useState } from 'react';
+import './ImageUpload.css';
+
+function ImageUpload({ value, onChange, label }) {
+ const [uploading, setUploading] = useState(false);
+ const [preview, setPreview] = useState(value || '');
+
+ const handleFileChange = async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ if (!file.type.startsWith('image/')) {
+ alert('Please select an image file');
+ return;
+ }
+
+ if (file.size > 5 * 1024 * 1024) {
+ alert('Image size must be less than 5MB');
+ return;
+ }
+
+ try {
+ setUploading(true);
+
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ setPreview(reader.result);
+ };
+ reader.readAsDataURL(file);
+
+ const formData = new FormData();
+ formData.append('image', file);
+
+ const response = await fetch('/api/upload/image', {
+ method: 'POST',
+ body: formData,
+ credentials: 'include'
+ });
+
+ if (!response.ok) {
+ throw new Error('Upload failed');
+ }
+
+ const data = await response.json();
+
+ onChange(data.url);
+ setPreview(data.url);
+
+ } catch (error) {
+ console.error('Upload error:', error);
+ alert('Failed to upload image. Please try again.');
+ setPreview('');
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleUrlChange = (url) => {
+ setPreview(url);
+ onChange(url);
+ };
+
+ const handleRemove = () => {
+ setPreview('');
+ onChange('');
+ };
+
+ return (
+
+
{label}
+
+
+
+
+
+ {uploading ? 'Uploading...' : '📁 Choose File'}
+
+ Max 5MB
+
+
+
+ OR
+
+
+
+ handleUrlChange(e.target.value)}
+ placeholder="Paste image URL"
+ className="url-input"
+ disabled={uploading}
+ />
+
+
+
+ {preview && (
+
+
+
+ ✕ Remove
+
+
+ )}
+
+ );
+}
+
+export default ImageUpload;
\ No newline at end of file
diff --git a/frontend/src/components/Seminars/ZoomMeeting.jsx b/frontend/src/components/Seminars/ZoomMeeting.jsx
new file mode 100644
index 0000000..3d51278
--- /dev/null
+++ b/frontend/src/components/Seminars/ZoomMeeting.jsx
@@ -0,0 +1,133 @@
+import React, { useEffect, useRef } from "react";
+import { ZoomMtg } from "@zoom/meetingsdk";
+
+const ZOOM_SDK_VERSION = process.env.REACT_APP_ZOOM_SDK_VERSION || "5.1.2";
+const ZOOM_SDK_LIB = `https://source.zoom.us/${ZOOM_SDK_VERSION}/lib`;
+
+function ZoomMeeting({ seminarId, meetingNumber, passWord, userFullName }) {
+ const initializedRef = useRef(false);
+ const cleanupTimerRef = useRef(null);
+
+ useEffect(() => {
+ const suppressZoomCancelErrors = (event) => {
+ const message = event?.message || event?.error?.message || event?.reason?.message || event?.reason;
+ if (typeof message === "string" && message.includes("Job was cancelled")) {
+ event.preventDefault();
+ if (typeof event.stopImmediatePropagation === "function") {
+ event.stopImmediatePropagation();
+ }
+ }
+ };
+
+ window.addEventListener("error", suppressZoomCancelErrors);
+ window.addEventListener("unhandledrejection", suppressZoomCancelErrors);
+
+ return () => {
+ window.removeEventListener("error", suppressZoomCancelErrors);
+ window.removeEventListener("unhandledrejection", suppressZoomCancelErrors);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (cleanupTimerRef.current) {
+ window.clearTimeout(cleanupTimerRef.current);
+ cleanupTimerRef.current = null;
+ }
+
+ const zoomRoot = document.getElementById("zmmtg-root");
+ if (zoomRoot) {
+ zoomRoot.style.display = "block";
+ }
+
+ const initZoom = async () => {
+ if (initializedRef.current) return;
+ initializedRef.current = true;
+
+ try {
+ ZoomMtg.setZoomJSLib(ZOOM_SDK_LIB, "/av");
+ ZoomMtg.preLoadWasm();
+ ZoomMtg.prepareWebSDK();
+
+ const apiBase = process.env.REACT_APP_API_URL || "";
+ const videoWebRtcMode = Number(process.env.REACT_APP_ZOOM_VIDEO_WEBRTC_MODE ?? 1);
+ const params = new URLSearchParams({
+ role: "0",
+ meetingNumber: String(meetingNumber)
+ });
+
+ if ([0, 1].includes(videoWebRtcMode)) {
+ params.set("videoWebRtcMode", String(videoWebRtcMode));
+ }
+
+ const signatureRes = await fetch(
+ `${apiBase}/api/academy/seminars/${seminarId}/zoom-signature?${params.toString()}`,
+ { credentials: "include" }
+ );
+
+ if (!signatureRes.ok) {
+ const errorData = await signatureRes.json().catch(() => ({}));
+ throw new Error(errorData.message || "Unable to get Zoom signature");
+ }
+
+ const { signature } = await signatureRes.json();
+ if (!signature) {
+ throw new Error("Invalid signature response");
+ }
+
+ ZoomMtg.init({
+ leaveUrl: `${window.location.origin}/academy/seminars/${seminarId}`,
+ patchJsMedia: true,
+ leaveOnPageUnload: true,
+ debug: false,
+ success: () => {
+ ZoomMtg.join({
+ signature,
+ meetingNumber: String(meetingNumber),
+ passWord: passWord || "",
+ userName: userFullName || "Guest User",
+ userEmail: "",
+ success: () => {},
+ error: (error) => {
+ initializedRef.current = false;
+ console.error("Zoom join error", error);
+ }
+ });
+ },
+ error: (error) => {
+ initializedRef.current = false;
+ console.error("Zoom init error", error);
+ }
+ });
+ } catch (error) {
+ initializedRef.current = false;
+ console.error("Zoom start error", error);
+ }
+ };
+
+ initZoom();
+
+ return () => {
+ cleanupTimerRef.current = window.setTimeout(() => {
+ const root = document.getElementById("zmmtg-root");
+ if (root) {
+ root.style.display = "none";
+ }
+
+ try {
+ ZoomMtg.leaveMeeting({});
+ } catch (error) {
+ // Ignore cleanup errors from zoom sdk.
+ }
+
+ document.body.style.overflow = "auto";
+ initializedRef.current = false;
+ }, 150);
+ };
+ }, [seminarId, meetingNumber, passWord, userFullName]);
+
+ return (
+
+ );
+}
+
+export default ZoomMeeting;
diff --git a/frontend/src/pages/Academy/Academy.jsx b/frontend/src/pages/Academy/Academy.jsx
index 830d544..7bd689f 100644
--- a/frontend/src/pages/Academy/Academy.jsx
+++ b/frontend/src/pages/Academy/Academy.jsx
@@ -1,4 +1,3 @@
-/* global process */
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
diff --git a/frontend/src/pages/Academy/Courses/AssignmentBuilder.css b/frontend/src/pages/Academy/Courses/AssignmentBuilder.css
new file mode 100644
index 0000000..cdaaae4
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/AssignmentBuilder.css
@@ -0,0 +1,439 @@
+.assignment-builder {
+ background: #f8f9fa;
+ border-radius: 8px;
+ padding: 1.5rem;
+}
+
+.assignment-builder h4 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #1a1a1a;
+ margin: 0 0 1.5rem 0;
+}
+
+/* No Assignment State */
+.no-assignment {
+ text-align: center;
+ padding: 2rem;
+ background: #f8f9fa;
+ border-radius: 8px;
+}
+
+.no-assignment p {
+ color: #999;
+ margin-bottom: 1rem;
+}
+
+.add-assignment-button {
+ background: #4F46E5;
+ color: white;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: 8px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.add-assignment-button:hover {
+ background: #4338CA;
+}
+
+/* Assignment Preview */
+.assignment-preview {
+ background: #f8f9fa;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 1.5rem;
+}
+
+.assignment-preview-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+}
+
+.assignment-preview h4 {
+ margin: 0 0 0.5rem 0;
+ color: #1a1a1a;
+ font-size: 1.1rem;
+}
+
+.assignment-purpose {
+ color: #666;
+ font-size: 0.9rem;
+ margin: 0 0 0.75rem 0;
+ line-height: 1.5;
+}
+
+.assignment-stats {
+ display: flex;
+ gap: 0.5rem;
+ font-size: 0.875rem;
+ color: #666;
+}
+
+.assignment-preview-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+.edit-button {
+ background: white;
+ color: #4F46E5;
+ border: 1px solid #4F46E5;
+ padding: 0.5rem 1rem;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: all 0.2s;
+}
+
+.edit-button:hover {
+ background: #4F46E5;
+ color: white;
+}
+
+/* Assignment Parts Section */
+.assignment-parts-section,
+.grading-criteria-section {
+ margin: 2rem 0;
+ padding: 1.5rem;
+ background: white;
+ border-radius: 8px;
+}
+
+.add-part-form,
+.add-criterion-form {
+ background: #f8f9fa;
+ padding: 1.5rem;
+ border-radius: 8px;
+ margin-bottom: 1.5rem;
+}
+
+/* Parts List */
+.parts-list {
+ margin-top: 1.5rem;
+}
+
+.part-item {
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1rem;
+}
+
+.part-item:last-child {
+ margin-bottom: 0;
+}
+
+.part-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.75rem;
+}
+
+.part-header strong {
+ color: #4F46E5;
+ font-size: 0.95rem;
+}
+
+.part-instructions {
+ color: #666;
+ font-size: 0.9rem;
+ line-height: 1.6;
+ margin: 0;
+}
+
+/* Grading Criteria */
+.criteria-list {
+ margin-top: 1.5rem;
+}
+
+.criterion-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.875rem 1rem;
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ margin-bottom: 0.5rem;
+}
+
+.criterion-item:last-child {
+ margin-bottom: 0;
+}
+
+.criterion-name {
+ flex: 1;
+ color: #333;
+ font-size: 0.95rem;
+}
+
+.criterion-points {
+ color: #4F46E5;
+ font-weight: 600;
+ margin-left: 1rem;
+ margin-right: 0.5rem;
+}
+
+.total-points {
+ margin-top: 1rem;
+ padding: 1rem;
+ background: #f0f9ff;
+ border: 1px solid #bae6fd;
+ border-radius: 6px;
+ text-align: right;
+ color: #0369a1;
+ font-size: 0.95rem;
+}
+
+.total-points strong {
+ font-size: 1rem;
+}
+
+/* Assignment Actions */
+.assignment-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+ margin-top: 2rem;
+ padding-top: 1.5rem;
+ border-top: 1px solid #e0e0e0;
+}
+
+.cancel-button {
+ background: white;
+ color: #666;
+ border: 1px solid #ddd;
+ padding: 0.75rem 1.5rem;
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 500;
+ transition: all 0.2s;
+}
+
+.cancel-button:hover {
+ background: #f8f9fa;
+ border-color: #ccc;
+}
+
+.save-button {
+ background: #10b981;
+ color: white;
+ border: none;
+ padding: 0.75rem 1.5rem;
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 500;
+ transition: background 0.2s;
+}
+
+.save-button:hover {
+ background: #059669;
+}
+
+/* Form Groups */
+.assignment-builder .form-group {
+ margin-bottom: 1rem;
+}
+
+.assignment-builder .form-group label {
+ display: block;
+ font-weight: 500;
+ color: #333;
+ margin-bottom: 0.5rem;
+ font-size: 0.9rem;
+}
+
+.assignment-builder .form-group input,
+.assignment-builder .form-group textarea {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-size: 0.95rem;
+ transition: border-color 0.2s;
+ font-family: inherit;
+}
+
+.assignment-builder .form-group input:focus,
+.assignment-builder .form-group textarea:focus {
+ outline: none;
+ border-color: #4F46E5;
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+.assignment-builder .form-row {
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+ gap: 1rem;
+}
+
+/* Add Button */
+.assignment-builder .add-button {
+ background: #4F46E5;
+ color: white;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: 8px;
+ font-weight: 500;
+ cursor: pointer;
+ margin-top: 1rem;
+ transition: background 0.2s;
+}
+
+.assignment-builder .add-button:hover {
+ background: #4338CA;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .assignment-preview-header {
+ flex-direction: column;
+ }
+
+ .assignment-preview-actions {
+ width: 100%;
+ justify-content: flex-start;
+ }
+
+ .assignment-builder .form-row {
+ grid-template-columns: 1fr;
+ }
+
+ .assignment-parts-section,
+ .grading-criteria-section {
+ padding: 1rem;
+ }
+
+ .assignment-actions {
+ flex-direction: column-reverse;
+ }
+
+ .assignment-actions button {
+ width: 100%;
+ }
+}
+
+/* Assignment Type Selector */
+.assignment-type-selector {
+ margin-bottom: 30px;
+}
+
+.type-options {
+ display: flex;
+ gap: 15px;
+ margin-top: 15px;
+}
+
+.type-option {
+ flex: 1;
+ border: 2px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 15px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.type-option:hover {
+ border-color: #4F46E5;
+ background: #f8f9fa;
+}
+
+.type-option.selected {
+ border-color: #4F46E5;
+ background: #eef2ff;
+}
+
+.type-option input[type="radio"] {
+ margin-right: 10px;
+}
+
+.type-option-content {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.type-option-content strong {
+ color: #333;
+ font-size: 16px;
+}
+
+.type-option-content span {
+ color: #666;
+ font-size: 13px;
+}
+
+/* Question-based styles */
+.assignment-questions-preview {
+ margin-top: 20px;
+ padding: 20px;
+ background: white;
+ border-radius: 8px;
+ border: 1px solid #e0e0e0;
+}
+
+.questions-list {
+ margin: 15px 0;
+}
+
+.question-preview-item {
+ background: #f8f9fa;
+ padding: 15px;
+ border-radius: 6px;
+ margin-bottom: 10px;
+ border-left: 4px solid #4F46E5;
+}
+
+.question-preview-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.question-number {
+ font-weight: 600;
+ color: #333;
+ margin-right: 10px;
+}
+
+.question-type-badge {
+ background: #4F46E5;
+ color: white;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 12px;
+ margin-right: 8px;
+}
+
+.question-points-badge {
+ background: #10b981;
+ color: white;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.question-preview-text {
+ margin: 0;
+ color: #555;
+ font-size: 14px;
+}
+
+.total-points {
+ margin-top: 15px;
+ padding-top: 15px;
+ border-top: 2px solid #e0e0e0;
+ font-size: 16px;
+ color: #333;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/AssignmentBuilder.jsx b/frontend/src/pages/Academy/Courses/AssignmentBuilder.jsx
new file mode 100644
index 0000000..31ca04f
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/AssignmentBuilder.jsx
@@ -0,0 +1,425 @@
+import React, { useState } from 'react';
+import './AssignmentBuilder.css';
+import AssignmentQuestionBuilder from './AssignmentQuestionBuilder';
+
+function AssignmentBuilder({ assignment, onSave, onRemove }) {
+ const [isBuilding, setIsBuilding] = useState(false);
+ const [assignmentType, setAssignmentType] = useState('question-based'); // 'question-based' or 'part-based'
+
+ // Question-based state
+ const [assignmentQuestions, setAssignmentQuestions] = useState([]);
+
+ // Part-based state (your original)
+ const [assignmentData, setAssignmentData] = useState(
+ assignment || {
+ title: '',
+ purpose: '',
+ instructions: '',
+ parts: [],
+ gradingCriteria: [],
+ deliverableFormat: '',
+ totalPoints: 30
+ }
+ );
+
+ const [newPart, setNewPart] = useState({
+ partNumber: 1,
+ title: '',
+ instructions: ''
+ });
+
+ const [newCriterion, setNewCriterion] = useState({
+ name: '',
+ points: 0
+ });
+
+ // Question-based handlers
+ const handleAddQuestion = (question) => {
+ const questionWithNumber = {
+ ...question,
+ questionNumber: assignmentQuestions.length + 1
+ };
+ setAssignmentQuestions([...assignmentQuestions, questionWithNumber]);
+ };
+
+ const removeQuestion = (index) => {
+ const updated = assignmentQuestions.filter((_, i) => i !== index);
+ const renumbered = updated.map((q, i) => ({ ...q, questionNumber: i + 1 }));
+ setAssignmentQuestions(renumbered);
+ };
+
+ // Part-based handlers (your original)
+ const addPart = () => {
+ if (!newPart.title || !newPart.instructions) {
+ alert('Please fill in part title and instructions');
+ return;
+ }
+
+ setAssignmentData({
+ ...assignmentData,
+ parts: [...assignmentData.parts, { ...newPart, partNumber: assignmentData.parts.length + 1 }]
+ });
+
+ setNewPart({ partNumber: assignmentData.parts.length + 2, title: '', instructions: '' });
+ };
+
+ const removePart = (index) => {
+ setAssignmentData({
+ ...assignmentData,
+ parts: assignmentData.parts.filter((_, i) => i !== index)
+ });
+ };
+
+ const addCriterion = () => {
+ if (!newCriterion.name || newCriterion.points <= 0) {
+ alert('Please fill in criterion name and points');
+ return;
+ }
+
+ setAssignmentData({
+ ...assignmentData,
+ gradingCriteria: [...assignmentData.gradingCriteria, newCriterion]
+ });
+
+ setNewCriterion({ name: '', points: 0 });
+ };
+
+ const removeCriterion = (index) => {
+ setAssignmentData({
+ ...assignmentData,
+ gradingCriteria: assignmentData.gradingCriteria.filter((_, i) => i !== index)
+ });
+ };
+
+ const handleSave = () => {
+ if (assignmentType === 'question-based') {
+ // Save question-based assignment
+ if (assignmentQuestions.length === 0) {
+ alert('Please add at least one question');
+ return;
+ }
+
+ const totalPoints = assignmentQuestions.reduce((sum, q) => sum + q.points, 0);
+
+ const newAssignment = {
+ title: 'Module Assignment',
+ instructions: 'Complete all questions below',
+ questions: assignmentQuestions,
+ totalPoints
+ };
+
+ onSave(newAssignment);
+ setIsBuilding(false);
+ setAssignmentQuestions([]);
+
+ } else {
+ // Save part-based assignment (your original)
+ if (!assignmentData.title) {
+ alert('Please enter an assignment title');
+ return;
+ }
+ if (!assignmentData.purpose) {
+ alert('Please enter the assignment purpose');
+ return;
+ }
+ if (assignmentData.parts.length === 0) {
+ alert('Please add at least one part');
+ return;
+ }
+ if (assignmentData.gradingCriteria.length === 0) {
+ alert('Please add grading criteria');
+ return;
+ }
+
+ onSave(assignmentData);
+ setIsBuilding(false);
+ }
+ };
+
+ const handleCancel = () => {
+ setAssignmentData({
+ title: '',
+ purpose: '',
+ instructions: '',
+ parts: [],
+ gradingCriteria: [],
+ deliverableFormat: '',
+ totalPoints: 30
+ });
+ setAssignmentQuestions([]);
+ setIsBuilding(false);
+ };
+
+ // Preview existing assignment
+ if (assignment && !isBuilding) {
+ const isQuestionBased = assignment.questions && assignment.questions.length > 0;
+
+ return (
+
+
+
+
{assignment.title || 'Module Assignment'}
+ {assignment.purpose &&
{assignment.purpose}
}
+
+ {isQuestionBased ? (
+ <>
+ {assignment.questions.length} questions
+ •
+ {assignment.totalPoints} points
+ >
+ ) : (
+ <>
+ {assignment.parts?.length || 0} parts
+ •
+ {assignment.gradingCriteria?.reduce((sum, c) => sum + c.points, 0) || 0} points
+ >
+ )}
+
+
+
+ setIsBuilding(true)} className="edit-button">
+ Edit
+
+
+ Remove
+
+
+
+
+ );
+ }
+
+ if (!isBuilding && !assignment) {
+ return (
+
+
No assignment added yet
+
setIsBuilding(true)} className="add-assignment-button">
+ + Add Assignment
+
+
+ );
+ }
+
+ return (
+
+ {/* Assignment Type Selector */}
+
+
+ {/* Question-Based Builder */}
+ {assignmentType === 'question-based' && (
+
+
+
+ {assignmentQuestions.length > 0 && (
+
+
Assignment Questions ({assignmentQuestions.length})
+
+ {assignmentQuestions.map((q, index) => (
+
+
+
+ Q{index + 1}
+ {q.type}
+ {q.points} pts
+
+
removeQuestion(index)} className="remove-button-small">
+ ✕
+
+
+
{q.question}
+
+ ))}
+
+
+ Total Points:
+ {assignmentQuestions.reduce((sum, q) => sum + q.points, 0)} pts
+
+
+ )}
+
+ )}
+
+ {/* Part-Based Builder (Your Original) */}
+ {assignmentType === 'part-based' && (
+
+
+ Assignment Title *
+ setAssignmentData({ ...assignmentData, title: e.target.value })}
+ placeholder="e.g., Building Your Brand Identity & Social Currency Strategy"
+ />
+
+
+
+ Purpose *
+
+
+
+ General Instructions (optional)
+
+
+
+
Assignment Parts
+
+
+
+ Part Title *
+ setNewPart({ ...newPart, title: e.target.value })}
+ placeholder="e.g., Define Your Brand Identity"
+ />
+
+
+
+ Part Instructions *
+
+
+
+ + Add Part
+
+
+
+ {assignmentData.parts.length > 0 && (
+
+ {assignmentData.parts.map((part, index) => (
+
+
+ Part {part.partNumber}: {part.title}
+ removePart(index)} className="remove-button-small">
+ ×
+
+
+
{part.instructions}
+
+ ))}
+
+ )}
+
+
+
+
Grading Criteria
+
+
+
+
+
+ + Add Criterion
+
+
+
+ {assignmentData.gradingCriteria.length > 0 && (
+
+ {assignmentData.gradingCriteria.map((criterion, index) => (
+
+ {criterion.name}
+ ({criterion.points} pts)
+ removeCriterion(index)} className="remove-button-small">
+ ×
+
+
+ ))}
+
+ Total Points:
+ {assignmentData.gradingCriteria.reduce((sum, c) => sum + c.points, 0)} pts
+
+
+ )}
+
+
+
+ Deliverable Format
+ setAssignmentData({ ...assignmentData, deliverableFormat: e.target.value })}
+ placeholder="e.g., Submit as a written document (Word or PDF)"
+ />
+
+
+ )}
+
+
+
+ Cancel
+
+
+ Save Assignment
+
+
+
+ );
+}
+
+export default AssignmentBuilder;
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/AssignmentLesson.jsx b/frontend/src/pages/Academy/Courses/AssignmentLesson.jsx
new file mode 100644
index 0000000..9dfc700
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/AssignmentLesson.jsx
@@ -0,0 +1,307 @@
+import React, { useState, useEffect } from 'react';
+import './CourseLearning.css';
+
+function AssignmentLesson({ courseId, lesson, onComplete, progress }) {
+ const [answers, setAnswers] = useState({});
+ const [fileUrl, setFileUrl] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [submitted, setSubmitted] = useState(false);
+
+ const assignment = lesson.assignmentData;
+ const isQuestionBased = assignment?.questions && assignment.questions.length > 0;
+
+ useEffect(() => {
+ // Check if already submitted
+ if (progress?.completedLessons?.includes(lesson._id)) {
+ setSubmitted(true);
+ }
+ }, [progress, lesson._id]);
+
+ const handleAnswerChange = (questionNumber, answer) => {
+ setAnswers({
+ ...answers,
+ [questionNumber]: answer
+ });
+ };
+
+ const handleMatchingChange = (questionNumber, pairIndex, value) => {
+ const currentAnswer = answers[questionNumber] || {};
+ setAnswers({
+ ...answers,
+ [questionNumber]: {
+ ...currentAnswer,
+ [pairIndex]: value
+ }
+ });
+ };
+
+ const handleSubmit = async () => {
+ if (!isQuestionBased) {
+ // Old part-based assignment
+ if (!fileUrl.trim()) {
+ alert('Please provide a file URL or link to your submission');
+ return;
+ }
+ } else {
+ // Question-based assignment - validate all questions answered
+ const unansweredQuestions = assignment.questions.filter(q => {
+ const answer = answers[q.questionNumber];
+
+ if (q.type === 'multiple-choice' || q.type === 'true-false') {
+ return answer === undefined || answer === null;
+ }
+ if (q.type === 'written') {
+ return !answer || answer.trim() === '';
+ }
+ if (q.type === 'matching') {
+ const pairs = assignment.questions.find(aq => aq.questionNumber === q.questionNumber)?.matchPairs || [];
+ return !answer || Object.keys(answer).length < pairs.length;
+ }
+ if (q.type === 'pdf-upload') {
+ return !answer || answer.trim() === '';
+ }
+ return false;
+ });
+
+ if (unansweredQuestions.length > 0) {
+ alert(`Please answer all questions. Missing: Question ${unansweredQuestions.map(q => q.questionNumber).join(', ')}`);
+ return;
+ }
+ }
+
+ try {
+ setSubmitting(true);
+
+ const submissionData = isQuestionBased
+ ? {
+ courseId,
+ moduleId: lesson._id.split('-')[0],
+ assignmentId: lesson._id,
+ answers,
+ submittedAt: new Date()
+ }
+ : {
+ courseId,
+ moduleId: lesson._id.split('-')[0],
+ assignmentId: lesson._id,
+ fileUrl,
+ submittedAt: new Date()
+ };
+
+ const res = await fetch('/api/assignments/submit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(submissionData)
+ });
+
+ if (!res.ok) {
+ throw new Error('Submission failed');
+ }
+
+ alert('Assignment submitted successfully!');
+ setSubmitted(true);
+ onComplete();
+
+ } catch (err) {
+ console.error('Error submitting assignment:', err);
+ alert('Failed to submit assignment. Please try again.');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (submitted) {
+ return (
+
+
{lesson.title}
+
+
+ ✅ Assignment Submitted
+
+
Your assignment has been submitted and is awaiting review from your instructor.
+
+
+ );
+ }
+
+ return (
+
+
{lesson.title}
+
+
+ {/* Instructions */}
+
+
Assignment Instructions:
+
{assignment?.instructions || assignment?.purpose || 'Complete all questions below'}
+
+
+ {/* Question-Based Assignment */}
+ {isQuestionBased && (
+
+ {assignment.questions.map((question, index) => (
+
+
+ Question {question.questionNumber}
+ {question.type}
+ {question.points} pts
+
+
+
{question.question}
+
+ {/* Multiple Choice */}
+ {question.type === 'multiple-choice' && (
+
+ {question.options.map((option, optIndex) => (
+
+ handleAnswerChange(question.questionNumber, optIndex)}
+ />
+ {option}
+
+ ))}
+
+ )}
+
+ {/* True/False */}
+ {question.type === 'true-false' && (
+
+
+ handleAnswerChange(question.questionNumber, 0)}
+ />
+ True
+
+
+ handleAnswerChange(question.questionNumber, 1)}
+ />
+ False
+
+
+ )}
+
+ {/* Written Response */}
+ {question.type === 'written' && (
+
+ )}
+
+ {/* Matching */}
+ {question.type === 'matching' && (
+
+
Match each item on the left with the correct item on the right:
+ {question.matchPairs.map((pair, pairIndex) => (
+
+
{pair.left}
+
→
+
handleMatchingChange(question.questionNumber, pairIndex, e.target.value)}
+ className="matching-select"
+ >
+ Select match...
+ {question.matchPairs.map((p, i) => (
+ {p.right}
+ ))}
+
+
+ ))}
+
+ )}
+
+ {/* PDF Upload */}
+ {question.type === 'pdf-upload' && (
+
+ {question.fileRequirements && (
+
+
File Requirements:
+
{question.fileRequirements}
+
+ )}
+
handleAnswerChange(question.questionNumber, e.target.value)}
+ placeholder="Paste link to your file (Google Drive, Dropbox, etc.)"
+ className="file-url-input"
+ />
+
Upload your file to Google Drive, Dropbox, or another cloud service, then paste the shareable link here.
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Part-Based Assignment (Old Structure) */}
+ {!isQuestionBased && (
+
+ {assignment.parts && assignment.parts.map((part, index) => (
+
+
Part {part.partNumber}: {part.title}
+
{part.instructions}
+
+ ))}
+
+
+
Attach File URL (optional)
+
setFileUrl(e.target.value)}
+ placeholder="https://docs.google.com/..."
+ className="file-url-input"
+ />
+
Paste a link to Google Doc, Dropbox file, etc.
+
+
+ )}
+
+ {/* Submit Button */}
+
+ {submitting ? 'Submitting...' : 'Submit Assignment'}
+
+
+
+ );
+}
+
+export default AssignmentLesson;
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/AssignmentQuestionBuilder.jsx b/frontend/src/pages/Academy/Courses/AssignmentQuestionBuilder.jsx
new file mode 100644
index 0000000..0edfd16
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/AssignmentQuestionBuilder.jsx
@@ -0,0 +1,259 @@
+import React, { useState } from 'react';
+
+function AssignmentQuestionBuilder({ onAddQuestion }) {
+ const [questionType, setQuestionType] = useState('multiple-choice');
+ const [question, setQuestion] = useState({
+ type: 'multiple-choice',
+ question: '',
+ points: 10,
+ // Multiple choice
+ options: ['', '', '', ''],
+ correctAnswer: null,
+ // Matching
+ matchPairs: [{ left: '', right: '' }],
+ // Written
+ wordLimit: 0,
+ rubric: '',
+ // PDF
+ fileRequirements: ''
+ });
+
+ const handleTypeChange = (type) => {
+ setQuestionType(type);
+ setQuestion({ ...question, type });
+ };
+
+ const handleAddQuestion = () => {
+ if (!question.question.trim()) {
+ alert('Please enter a question');
+ return;
+ }
+
+ // Validate based on type
+ if (question.type === 'multiple-choice') {
+ if (question.options.some(opt => !opt.trim())) {
+ alert('Please fill in all answer options');
+ return;
+ }
+ if (question.correctAnswer === null) {
+ alert('Please select the correct answer');
+ return;
+ }
+ }
+
+ if (question.type === 'matching') {
+ if (question.matchPairs.some(pair => !pair.left.trim() || !pair.right.trim())) {
+ alert('Please fill in all matching pairs');
+ return;
+ }
+ }
+
+ onAddQuestion(question);
+
+ // Reset
+ setQuestion({
+ type: questionType,
+ question: '',
+ points: 10,
+ options: ['', '', '', ''],
+ correctAnswer: null,
+ matchPairs: [{ left: '', right: '' }],
+ wordLimit: 0,
+ rubric: '',
+ fileRequirements: ''
+ });
+ };
+
+ const addMatchPair = () => {
+ setQuestion({
+ ...question,
+ matchPairs: [...question.matchPairs, { left: '', right: '' }]
+ });
+ };
+
+ const removeMatchPair = (index) => {
+ setQuestion({
+ ...question,
+ matchPairs: question.matchPairs.filter((_, i) => i !== index)
+ });
+ };
+
+ return (
+
+
Add Assignment Question
+
+ {/* Question Type Selector */}
+
+ Question Type
+ handleTypeChange(e.target.value)}>
+ Multiple Choice
+ Written Response
+ Matching
+ PDF Upload
+ True/False
+
+
+
+ {/* Question Text */}
+
+ Question *
+
+
+ {/* Points */}
+
+ Points
+ setQuestion({ ...question, points: parseInt(e.target.value) })}
+ min="1"
+ />
+
+
+ {/* Type-specific fields */}
+ {questionType === 'multiple-choice' && (
+ <>
+
+ Answer Options
+ {question.options.map((option, index) => (
+ {
+ const newOptions = [...question.options];
+ newOptions[index] = e.target.value;
+ setQuestion({ ...question, options: newOptions });
+ }}
+ placeholder={`Option ${index + 1}`}
+ style={{ marginBottom: '8px' }}
+ />
+ ))}
+
+
+
+ Correct Answer
+ setQuestion({ ...question, correctAnswer: parseInt(e.target.value) })}
+ >
+ Select correct answer...
+ {question.options.map((option, index) => (
+
+ {option || `Option ${index + 1}`}
+
+ ))}
+
+
+ >
+ )}
+
+ {questionType === 'true-false' && (
+
+ Correct Answer
+ setQuestion({ ...question, correctAnswer: parseInt(e.target.value) })}
+ >
+ Select correct answer...
+ True
+ False
+
+
+ )}
+
+ {questionType === 'written' && (
+ <>
+
+ Word Limit (0 = no limit)
+ setQuestion({ ...question, wordLimit: parseInt(e.target.value) })}
+ min="0"
+ />
+
+
+
+ Grading Rubric (optional)
+
+ >
+ )}
+
+ {questionType === 'matching' && (
+
+ )}
+
+ {questionType === 'pdf-upload' && (
+
+ File Requirements
+
+ )}
+
+
+ Add Question
+
+
+ );
+}
+
+export default AssignmentQuestionBuilder;
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/CourseCompleteModal.jsx b/frontend/src/pages/Academy/Courses/CourseCompleteModal.jsx
new file mode 100644
index 0000000..52fc20e
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/CourseCompleteModal.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import './CourseLearning.css';
+
+function CourseCompleteModal({ course, badge, onClose }) {
+ return (
+
+
+
🎉
+
+
Congratulations!
+
You've completed the course:
+
{course.title}
+
+ {badge && badge.name && (
+
+
+ {badge.imageUrl ? (
+
+ ) : (
+
🏆
+ )}
+
+
{badge.name}
+
{badge.description}
+
+ )}
+
+
+ Return to My Courses
+
+
+
+ );
+}
+
+export default CourseCompleteModal;
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/CourseDetail.jsx b/frontend/src/pages/Academy/Courses/CourseDetail.jsx
index 0b011f6..76f0dab 100644
--- a/frontend/src/pages/Academy/Courses/CourseDetail.jsx
+++ b/frontend/src/pages/Academy/Courses/CourseDetail.jsx
@@ -1,4 +1,3 @@
-/* global process */
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
@@ -28,11 +27,6 @@ const stripePromise = loadStripe(
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY
);
-console.log(
- "Stripe publishable key:",
- process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY
-);
-
function CourseDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -109,8 +103,10 @@ function CourseDetail() {
const fetchCourse = async () => {
try {
setLoading(true);
-
- const response = await fetch(`http://localhost:5000/api/academy/courses/${id}`);
+ const apiBase = process.env.REACT_APP_API_URL || '';
+ const response = await fetch(`${apiBase}/api/academy/courses/${id}`, {
+ credentials: 'include',
+ });
if (!response.ok) {
throw new Error('Course not found');
@@ -148,19 +144,25 @@ function CourseDetail() {
try {
setEnrolling(true);
+ const apiBase = process.env.REACT_APP_API_URL || '';
const res = await fetch(
- "http://localhost:5000/api/payments/create-payment-intent",
+ `${apiBase}/api/payments/create-payment-intent`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
+ credentials: 'include',
body: JSON.stringify({
courseId: course._id,
- userId: "TEMP_USER_ID", // replace with real auth user later
}),
}
);
+ if (res.status === 401) {
+ navigate(`/login?returnTo=${encodeURIComponent(`/academy/courses/${course._id}`)}`);
+ return;
+ }
+
const data = await res.json();
if (!res.ok) {
diff --git a/frontend/src/pages/Academy/Courses/CourseLearning.css b/frontend/src/pages/Academy/Courses/CourseLearning.css
new file mode 100644
index 0000000..4e1f5a5
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/CourseLearning.css
@@ -0,0 +1,1417 @@
+/* ================================================
+ COURSE LEARNING PAGE - MAIN LAYOUT
+ ================================================ */
+
+.course-learning-page {
+ min-height: 100vh;
+ background: #f5f5f5;
+}
+
+.course-learning-header {
+ background: #fff;
+ padding: 1.5rem 2rem;
+ border-bottom: 1px solid #e5e5e5;
+ display: flex;
+ align-items: center;
+ gap: 2rem;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.exit-button {
+ background: #f3f4f6;
+ border: none;
+ padding: 0.75rem 1.5rem;
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 600;
+ transition: background 0.2s;
+}
+
+.exit-button:hover {
+ background: #e5e7eb;
+}
+
+.course-learning-header h1 {
+ flex: 1;
+ margin: 0;
+ font-size: 1.5rem;
+ color: #1a1a1a;
+}
+
+.progress-info {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ font-weight: 600;
+ color: #555;
+}
+
+.progress-percentage {
+ background: #4F46E5;
+ color: white;
+ padding: 0.5rem 1rem;
+ border-radius: 20px;
+ font-size: 0.9rem;
+}
+
+.course-learning-container {
+ display: grid;
+ grid-template-columns: 350px 1fr;
+ gap: 0;
+ min-height: calc(100vh - 100px);
+}
+
+/* ================================================
+ SIDEBAR
+ ================================================ */
+
+.course-sidebar {
+ background: #fff;
+ border-right: 1px solid #e5e5e5;
+ overflow-y: auto;
+ max-height: calc(100vh - 100px);
+}
+
+.sidebar-header {
+ padding: 1.5rem;
+ border-bottom: 1px solid #e5e5e5;
+ position: sticky;
+ top: 0;
+ background: #fff;
+ z-index: 10;
+}
+
+.sidebar-header h3 {
+ margin: 0;
+ font-size: 1.1rem;
+ color: #1a1a1a;
+}
+
+.modules-list {
+ padding: 1rem 0;
+}
+
+.module-item {
+ border-bottom: 1px solid #f3f4f6;
+}
+
+.module-header {
+ padding: 1rem 1.5rem;
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: background 0.2s;
+}
+
+.module-header:hover {
+ background: #f9fafb;
+}
+
+.module-title {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.module-number {
+ font-size: 0.75rem;
+ color: #6b7280;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.module-name {
+ font-weight: 600;
+ color: #1a1a1a;
+}
+
+.expand-icon {
+ font-size: 1.5rem;
+ color: #9ca3af;
+}
+
+.lessons-list {
+ background: #f9fafb;
+}
+
+.lesson-item {
+ padding: 1rem 1.5rem 1rem 2.5rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-left: 3px solid transparent;
+}
+
+.lesson-item:hover:not(.locked) {
+ background: #f3f4f6;
+ border-left-color: #4F46E5;
+}
+
+.lesson-item.current {
+ background: #eef2ff;
+ border-left-color: #4F46E5;
+}
+
+.lesson-item.completed {
+ opacity: 0.7;
+}
+
+.lesson-item.locked {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.lesson-content {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ flex: 1;
+}
+
+.lesson-icon {
+ font-size: 1.2rem;
+ flex-shrink: 0;
+}
+
+.lesson-info {
+ flex: 1;
+}
+
+.lesson-title {
+ font-weight: 500;
+ color: #1a1a1a;
+ font-size: 0.9rem;
+ margin-bottom: 0.25rem;
+}
+
+.lesson-meta {
+ display: flex;
+ gap: 0.75rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.lesson-duration,
+.lesson-type {
+ text-transform: capitalize;
+}
+
+.lock-icon {
+ font-size: 1rem;
+}
+
+.final-test-item .module-header {
+ background: #fef3c7;
+}
+
+.test-badge {
+ background: #f59e0b;
+ color: white;
+ padding: 0.25rem 0.75rem;
+ border-radius: 12px;
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+
+/* ================================================
+ MAIN CONTENT AREA
+ ================================================ */
+
+.course-learning-main {
+ padding: 2rem;
+ overflow-y: auto;
+ max-height: calc(100vh - 100px);
+}
+
+.lesson-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 2rem;
+}
+
+.lesson-header h2 {
+ margin: 0;
+ font-size: 1.75rem;
+ color: #1a1a1a;
+}
+
+.duration-badge,
+.passing-score {
+ background: #f3f4f6;
+ padding: 0.5rem 1rem;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: #6b7280;
+}
+
+.lesson-navigation {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 2rem;
+ padding-top: 2rem;
+ border-top: 1px solid #e5e5e5;
+}
+
+.nav-button {
+ padding: 0.75rem 2rem;
+ border: none;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.prev-button {
+ background: #f3f4f6;
+ color: #374151;
+}
+
+.prev-button:hover:not(:disabled) {
+ background: #e5e7eb;
+}
+
+.next-button {
+ background: #4F46E5;
+ color: white;
+}
+
+.next-button:hover {
+ background: #4338ca;
+}
+
+.nav-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ================================================
+ VIDEO LESSON
+ ================================================ */
+
+.video-lesson {
+ background: #fff;
+ border-radius: 12px;
+ padding: 2rem;
+}
+
+.video-container {
+ position: relative;
+ padding-bottom: 56.25%; /* 16:9 aspect ratio */
+ height: 0;
+ overflow: hidden;
+ background: #000;
+ border-radius: 12px;
+ margin-bottom: 2rem;
+}
+
+.video-player {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border: none;
+}
+
+.video-placeholder {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: #fff;
+ font-size: 1.2rem;
+}
+
+.lesson-actions {
+ display: flex;
+ justify-content: center;
+}
+
+.complete-button {
+ background: #10b981;
+ color: white;
+ border: none;
+ padding: 1rem 3rem;
+ border-radius: 8px;
+ font-size: 1.1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.complete-button:hover {
+ background: #059669;
+}
+
+.completed-badge {
+ background: #d1fae5;
+ color: #065f46;
+ padding: 1rem 3rem;
+ border-radius: 8px;
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+/* ================================================
+ READING & PODCAST LESSONS
+ ================================================ */
+
+.reading-lesson,
+.podcast-lesson {
+ background: #fff;
+ border-radius: 12px;
+ padding: 2rem;
+}
+
+.lesson-content-box {
+ background: #f9fafb;
+ padding: 1.5rem;
+ border-radius: 8px;
+ margin-bottom: 2rem;
+}
+
+.lesson-content-box h3 {
+ margin: 0 0 1rem;
+ color: #1a1a1a;
+ font-size: 1.1rem;
+}
+
+.reading-author {
+ color: #666;
+ font-style: italic;
+ margin: 0.5rem 0;
+}
+
+.reading-citation {
+ color: #555;
+ margin: 0.5rem 0 1rem;
+ font-weight: 500;
+}
+
+.reading-description,
+.podcast-description {
+ color: #4b5563;
+ line-height: 1.6;
+ margin-bottom: 1.5rem;
+}
+
+.reading-link-button,
+.podcast-link-button {
+ display: inline-block;
+ background: #4F46E5;
+ color: white;
+ padding: 0.875rem 1.5rem;
+ border-radius: 8px;
+ text-decoration: none;
+ font-weight: 600;
+ transition: background 0.2s;
+}
+
+.reading-link-button:hover,
+.podcast-link-button:hover {
+ background: #4338ca;
+}
+
+/* ================================================
+ ASSIGNMENT LESSON
+ ================================================ */
+
+.assignment-lesson {
+ background: #fff;
+ border-radius: 12px;
+ padding: 2rem;
+}
+
+.assignment-content {
+ max-width: 900px;
+}
+
+.assignment-instructions-box {
+ background: #fef3c7;
+ padding: 1.5rem;
+ border-radius: 8px;
+ margin-bottom: 2rem;
+}
+
+.assignment-instructions-box h3 {
+ margin: 0 0 0.75rem;
+ color: #92400e;
+}
+
+.assignment-instructions-box p {
+ margin: 0;
+ color: #78350f;
+ line-height: 1.6;
+}
+
+.assignment-parts-input {
+ margin: 2rem 0;
+}
+
+.part-input-section {
+ background: #f0f9ff;
+ padding: 1.5rem;
+ border-radius: 8px;
+ margin-bottom: 1.5rem;
+ border-left: 4px solid #4F46E5;
+}
+
+.part-input-section h4 {
+ margin: 0 0 0.5rem;
+ color: #4F46E5;
+ font-size: 1.05rem;
+}
+
+.part-instructions {
+ margin: 0 0 1rem;
+ color: #555;
+ line-height: 1.5;
+}
+
+.part-input-field {
+ width: 100%;
+ padding: 0.875rem;
+ border: 2px solid #cbd5e1;
+ border-radius: 6px;
+ font-size: 0.95rem;
+ font-family: inherit;
+ resize: vertical;
+ transition: border-color 0.2s;
+}
+
+.part-input-field:focus {
+ outline: none;
+ border-color: #4F46E5;
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+.grading-display {
+ margin: 2rem 0;
+ padding: 1.5rem;
+ background: #f9fafb;
+ border-radius: 8px;
+}
+
+.grading-display h3 {
+ margin: 0 0 1rem;
+ color: #1a1a1a;
+}
+
+.grading-display ul {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 1rem;
+}
+
+.grading-display li {
+ padding: 0.75rem 0;
+ border-bottom: 1px solid #e5e7eb;
+ color: #4b5563;
+}
+
+.grading-display li:last-child {
+ border-bottom: none;
+}
+
+.total-points-display {
+ padding: 1rem;
+ background: #e0f2fe;
+ border-radius: 6px;
+ font-weight: 600;
+ color: #0369a1;
+ text-align: right;
+}
+
+.deliverable-info {
+ margin: 1.5rem 0;
+ padding: 1rem;
+ background: #fef3c7;
+ border-radius: 6px;
+ color: #78350f;
+}
+
+.file-url-section {
+ margin: 2rem 0;
+ padding: 1.5rem;
+ background: #f9fafb;
+ border-radius: 8px;
+}
+
+.file-url-section label {
+ display: block;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ color: #374151;
+}
+
+.file-url-input {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 0.95rem;
+}
+
+.file-url-section small {
+ display: block;
+ margin-top: 0.5rem;
+ color: #6b7280;
+ font-size: 0.875rem;
+}
+
+.assignment-submit-section {
+ margin-top: 2rem;
+ text-align: center;
+}
+
+.submit-button {
+ background: #4F46E5;
+ color: white;
+ border: none;
+ padding: 1rem 3rem;
+ border-radius: 8px;
+ font-size: 1.1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.submit-button:hover {
+ background: #4338ca;
+}
+
+.submit-button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.submitted-badge-large {
+ background: #d1fae5;
+ color: #065f46;
+ padding: 2rem;
+ border-radius: 12px;
+ font-size: 1.5rem;
+ font-weight: 600;
+ text-align: center;
+ margin-bottom: 2rem;
+}
+
+.submission-view {
+ background: #f9fafb;
+ padding: 2rem;
+ border-radius: 8px;
+}
+
+.submission-view h4 {
+ margin: 0 0 1rem;
+ color: #1a1a1a;
+}
+
+.submission-text {
+ margin: 0;
+ color: #4b5563;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ font-family: inherit;
+}
+
+/* ================================================
+ QUIZ LESSON
+ ================================================ */
+
+.quiz-lesson {
+ background: #fff;
+ border-radius: 12px;
+ padding: 2rem;
+}
+
+.quiz-content {
+ max-width: 800px;
+}
+
+.quiz-question {
+ background: #f9fafb;
+ padding: 1.5rem;
+ border-radius: 8px;
+ margin-bottom: 2rem;
+}
+
+.quiz-question h4 {
+ margin: 0 0 1rem;
+ color: #1a1a1a;
+}
+
+.quiz-question p {
+ margin: 0 0 1rem;
+ color: #374151;
+ font-size: 1.05rem;
+ line-height: 1.6;
+}
+
+.quiz-options {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.quiz-option {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem;
+ background: #fff;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.quiz-option:hover {
+ border-color: #4F46E5;
+ background: #eef2ff;
+}
+
+.quiz-option input[type="radio"] {
+ width: 1.25rem;
+ height: 1.25rem;
+ cursor: pointer;
+}
+
+.quiz-option span {
+ flex: 1;
+ color: #1a1a1a;
+}
+
+.short-answer-input {
+ width: 100%;
+ padding: 0.75rem;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ font-size: 1rem;
+}
+
+.submit-quiz-button {
+ background: #4F46E5;
+ color: white;
+ border: none;
+ padding: 1rem 3rem;
+ border-radius: 8px;
+ font-size: 1.1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+ margin-top: 1rem;
+}
+
+.submit-quiz-button:hover {
+ background: #4338ca;
+}
+
+.quiz-result,
+.quiz-passed {
+ background: #f9fafb;
+ padding: 2rem;
+ border-radius: 12px;
+ text-align: center;
+}
+
+.quiz-result.passed {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.quiz-result.failed {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.quiz-result h3,
+.quiz-passed h3 {
+ margin: 0 0 1rem;
+ font-size: 1.5rem;
+}
+
+.quiz-result p,
+.quiz-passed p {
+ margin: 0.5rem 0;
+ font-size: 1.1rem;
+}
+
+.quiz-result button {
+ margin-top: 1.5rem;
+ background: #4F46E5;
+ color: white;
+ border: none;
+ padding: 0.75rem 2rem;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+/* ================================================
+ FINAL TEST
+ ================================================ */
+
+.final-test {
+ background: #fff;
+ border-radius: 12px;
+ padding: 2rem;
+}
+
+.test-header {
+ text-align: center;
+ margin-bottom: 3rem;
+ padding-bottom: 2rem;
+ border-bottom: 2px solid #e5e7eb;
+}
+
+.test-header h2 {
+ margin: 0 0 1rem;
+ font-size: 2rem;
+ color: #1a1a1a;
+}
+
+.test-header p {
+ color: #6b7280;
+ font-size: 1.1rem;
+ margin-bottom: 1rem;
+}
+
+.test-info {
+ display: flex;
+ justify-content: center;
+ gap: 2rem;
+ font-weight: 600;
+ color: #4F46E5;
+}
+
+.test-content {
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+.test-question {
+ background: #f9fafb;
+ padding: 2rem;
+ border-radius: 12px;
+ margin-bottom: 2rem;
+}
+
+.test-question h4 {
+ margin: 0 0 1rem;
+ color: #1a1a1a;
+ font-size: 1.2rem;
+}
+
+.test-question p {
+ margin: 0 0 1.5rem;
+ color: #374151;
+ font-size: 1.1rem;
+ line-height: 1.6;
+}
+
+.test-options {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.test-option {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1.25rem;
+ background: #fff;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.test-option:hover {
+ border-color: #4F46E5;
+ background: #eef2ff;
+}
+
+.test-option input[type="radio"] {
+ width: 1.5rem;
+ height: 1.5rem;
+ cursor: pointer;
+}
+
+.test-option span {
+ flex: 1;
+ color: #1a1a1a;
+ font-size: 1.05rem;
+}
+
+.submit-test-button {
+ background: #4F46E5;
+ color: white;
+ border: none;
+ padding: 1.25rem 4rem;
+ border-radius: 8px;
+ font-size: 1.2rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+ display: block;
+ margin: 2rem auto 0;
+}
+
+.submit-test-button:hover {
+ background: #4338ca;
+}
+
+/* ================================================
+ COURSE COMPLETE MODAL
+ ================================================ */
+
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.75);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.complete-modal {
+ background: #fff;
+ border-radius: 16px;
+ padding: 3rem;
+ max-width: 500px;
+ text-align: center;
+ position: relative;
+ animation: slideIn 0.3s ease-out;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateY(-50px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.confetti {
+ font-size: 4rem;
+ margin-bottom: 1rem;
+ animation: bounce 0.5s ease-in-out 3;
+}
+
+@keyframes bounce {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-20px); }
+}
+
+.complete-modal h1 {
+ margin: 0 0 1rem;
+ color: #1a1a1a;
+ font-size: 2rem;
+}
+
+.complete-modal p {
+ margin: 0 0 0.5rem;
+ color: #6b7280;
+}
+
+.complete-modal h2 {
+ margin: 0 0 2rem;
+ color: #4F46E5;
+ font-size: 1.5rem;
+}
+
+.badge-display {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ padding: 2rem;
+ border-radius: 12px;
+ margin: 2rem 0;
+ color: white;
+}
+
+.badge-icon {
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+ margin: 0 auto 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(255, 255, 255, 0.2);
+ border: 4px solid rgba(255, 255, 255, 0.3);
+}
+
+.badge-icon img {
+ width: 60px;
+ height: 60px;
+ object-fit: contain;
+}
+
+.badge-emoji {
+ font-size: 3rem;
+}
+
+.badge-display h3 {
+ margin: 0 0 0.5rem;
+ font-size: 1.5rem;
+}
+
+.badge-display p {
+ margin: 0;
+ opacity: 0.9;
+ color: white;
+}
+
+.close-modal-button {
+ background: #4F46E5;
+ color: white;
+ border: none;
+ padding: 1rem 2rem;
+ border-radius: 8px;
+ font-size: 1.1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.close-modal-button:hover {
+ background: #4338ca;
+}
+
+/* ================================================
+ LOADING & ERROR STATES
+ ================================================ */
+
+.loading,
+.error,
+.no-lesson {
+ text-align: center;
+ padding: 4rem 2rem;
+ color: #6b7280;
+ font-size: 1.2rem;
+}
+
+/* ================================================
+ RESPONSIVE
+ ================================================ */
+
+@media (max-width: 1024px) {
+ .course-learning-container {
+ grid-template-columns: 300px 1fr;
+ }
+}
+
+@media (max-width: 768px) {
+ .course-learning-container {
+ grid-template-columns: 1fr;
+ }
+
+ .course-sidebar {
+ max-height: 300px;
+ }
+
+ .course-learning-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+
+ .lesson-navigation {
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .nav-button {
+ width: 100%;
+ }
+}
+/* Assignment Lesson Container */
+.assignment-lesson {
+ width: 100%;
+ max-width: 100%;
+ padding: 0;
+}
+
+.assignment-lesson h2 {
+ padding: 0 40px;
+ margin-bottom: 20px;
+}
+
+.assignment-lesson .lesson-content {
+ width: 100%;
+ padding: 0;
+}
+
+/* Assignment Instructions */
+.assignment-instructions {
+ background: #fff9e6;
+ border-left: 4px solid #ffc107;
+ padding: 20px 40px;
+ margin-bottom: 30px;
+}
+
+.assignment-instructions h3 {
+ margin: 0 0 10px 0;
+ color: #856404;
+}
+
+.assignment-instructions p {
+ margin: 0;
+ color: #856404;
+}
+
+/* Assignment Questions - Full Width */
+.assignment-questions {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ margin: 0;
+ padding: 0;
+}
+
+.assignment-question {
+ background: #ffffff;
+ padding: 40px;
+ margin: 0;
+ border-radius: 0;
+ border-left: 4px solid #4F46E5;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.assignment-question:last-child {
+ border-bottom: none;
+}
+
+.question-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+
+.question-number {
+ font-weight: 600;
+ color: #333;
+ font-size: 18px;
+}
+
+.question-type-badge {
+ background: #4F46E5;
+ color: white;
+ padding: 6px 14px;
+ border-radius: 14px;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.question-points {
+ background: #10b981;
+ color: white;
+ padding: 6px 14px;
+ border-radius: 14px;
+ font-size: 13px;
+ font-weight: 600;
+ margin-left: auto;
+}
+
+.question-text {
+ font-size: 18px;
+ color: #1f2937;
+ margin-bottom: 24px;
+ line-height: 1.7;
+ font-weight: 500;
+}
+
+/* Multiple Choice & True/False - Full Width */
+.question-options {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ max-width: 800px;
+}
+
+.option-label {
+ display: flex;
+ align-items: center;
+ padding: 16px 20px;
+ background: #f9fafb;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.option-label:hover {
+ border-color: #4F46E5;
+ background: #eef2ff;
+ transform: translateX(4px);
+}
+
+.option-label input[type="radio"]:checked + .option-text {
+ font-weight: 600;
+}
+
+.option-label input[type="radio"] {
+ margin-right: 16px;
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+}
+
+.option-text {
+ font-size: 16px;
+ color: #374151;
+}
+
+/* Written Response - Full Width */
+.written-response {
+ max-width: 900px;
+}
+
+.written-response textarea {
+ width: 100%;
+ padding: 18px;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ font-size: 16px;
+ font-family: inherit;
+ resize: vertical;
+ min-height: 200px;
+ line-height: 1.6;
+}
+
+.written-response textarea:focus {
+ outline: none;
+ border-color: #4F46E5;
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+.word-count {
+ margin-top: 10px;
+ font-size: 14px;
+ color: #6b7280;
+}
+
+.rubric-info {
+ margin-top: 20px;
+ padding: 16px;
+ background: #fff9e6;
+ border-left: 4px solid #ffc107;
+ border-radius: 6px;
+}
+
+.rubric-info strong {
+ display: block;
+ margin-bottom: 8px;
+ color: #856404;
+ font-size: 15px;
+}
+
+.rubric-info p {
+ margin: 0;
+ color: #856404;
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+/* Matching - Full Width */
+.matching-question {
+ background: #f9fafb;
+ padding: 24px;
+ border-radius: 8px;
+ max-width: 800px;
+}
+
+.matching-instructions {
+ margin-bottom: 24px;
+ font-weight: 500;
+ color: #374151;
+ font-size: 15px;
+}
+
+.matching-pair {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ margin-bottom: 16px;
+ padding: 16px;
+ background: white;
+ border-radius: 8px;
+ border: 1px solid #e5e7eb;
+}
+
+.matching-left {
+ flex: 1;
+ font-weight: 500;
+ color: #1f2937;
+ font-size: 16px;
+}
+
+.matching-arrow {
+ color: #4F46E5;
+ font-size: 20px;
+ font-weight: bold;
+}
+
+.matching-select {
+ flex: 1;
+ padding: 12px 16px;
+ border: 2px solid #e5e7eb;
+ border-radius: 6px;
+ font-size: 15px;
+ background: white;
+ cursor: pointer;
+}
+
+.matching-select:focus {
+ outline: none;
+ border-color: #4F46E5;
+}
+
+/* PDF Upload - Full Width */
+.pdf-upload {
+ background: #f9fafb;
+ padding: 24px;
+ border-radius: 8px;
+ max-width: 900px;
+}
+
+.file-requirements {
+ margin-bottom: 20px;
+ padding: 16px;
+ background: #dbeafe;
+ border-left: 4px solid #3b82f6;
+ border-radius: 6px;
+}
+
+.file-requirements strong {
+ display: block;
+ margin-bottom: 8px;
+ color: #1e40af;
+ font-size: 15px;
+}
+
+.file-requirements p {
+ margin: 0;
+ color: #1e40af;
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.file-url-input {
+ width: 100%;
+ padding: 14px 16px;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ font-size: 15px;
+}
+
+.file-url-input:focus {
+ outline: none;
+ border-color: #4F46E5;
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+.file-help-text {
+ margin-top: 10px;
+ font-size: 13px;
+ color: #6b7280;
+}
+
+/* Submit Button - Full Width with Padding */
+.submit-assignment-button {
+ width: calc(100% - 80px);
+ margin: 40px 40px;
+ padding: 18px;
+ background: #4F46E5;
+ color: white;
+ border: none;
+ border-radius: 10px;
+ font-size: 17px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ box-shadow: 0 4px 6px rgba(79, 70, 229, 0.2);
+}
+
+.submit-assignment-button:hover:not(:disabled) {
+ background: #4338ca;
+ transform: translateY(-2px);
+ box-shadow: 0 6px 12px rgba(79, 70, 229, 0.3);
+}
+
+.submit-assignment-button:disabled {
+ background: #9ca3af;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+/* Assignment Submitted */
+.assignment-submitted {
+ background: #d1fae5;
+ border: 2px solid #10b981;
+ border-radius: 12px;
+ padding: 40px;
+ text-align: center;
+ margin: 40px;
+}
+
+.success-message {
+ font-size: 28px;
+ font-weight: 700;
+ color: #059669;
+ margin-bottom: 16px;
+}
+
+.assignment-submitted p {
+ font-size: 16px;
+ color: #047857;
+ margin: 0;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .assignment-question {
+ padding: 24px 20px;
+ }
+
+ .assignment-instructions {
+ padding: 16px 20px;
+ }
+
+ .submit-assignment-button {
+ width: calc(100% - 40px);
+ margin: 30px 20px;
+ }
+
+ .question-text {
+ font-size: 16px;
+ }
+
+ .matching-pair {
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .matching-left,
+ .matching-select {
+ width: 100%;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/CourseLearning.jsx b/frontend/src/pages/Academy/Courses/CourseLearning.jsx
new file mode 100644
index 0000000..a9975e9
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/CourseLearning.jsx
@@ -0,0 +1,455 @@
+import React, { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { FiArrowLeft } from "react-icons/fi";
+import CourseSidebar from "./CourseSidebar";
+import VideoLesson from "./VideoLesson";
+import AssignmentLesson from "./AssignmentLesson";
+import ReadingLesson from "./ReadingLesson";
+import PodcastLesson from "./PodcastLesson";
+import QuizLesson from "./QuizLesson";
+import FinalTest from "./FinalTest";
+import CourseCompleteModal from "./CourseCompleteModal";
+import "./CourseLearning.css";
+
+function CourseLearning() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+
+ const [course, setCourse] = useState(null);
+ const [progress, setProgress] = useState(null);
+ const [currentLesson, setCurrentLesson] = useState(null);
+ const [currentModule, setCurrentModule] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [showFinalTest, setShowFinalTest] = useState(false);
+ const [showCompleteModal, setShowCompleteModal] = useState(false);
+
+ useEffect(() => {
+ let startMs = Date.now();
+ let flushed = false;
+
+ const flush = async (reason) => {
+ if (flushed) {
+ return;
+ }
+ flushed = true;
+
+ const elapsedSec = Math.floor((Date.now() - startMs) / 1000);
+ if (elapsedSec <= 0) {
+ return;
+ }
+
+ const payload = JSON.stringify({ courseId: id, elapsedSec, reason });
+
+ try {
+ if (navigator.sendBeacon) {
+ navigator.sendBeacon(
+ "/api/learning/track",
+ new Blob([payload], { type: "application/json" })
+ );
+ return;
+ }
+ } catch {
+ // Fall through to fetch.
+ }
+
+ try {
+ await fetch("/api/learning/track", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: payload,
+ });
+ } catch (error) {
+ console.log("Failed to track time", error);
+ }
+ };
+
+ const onVisibilityChange = () => {
+ if (document.visibilityState === "hidden") {
+ flush("hidden");
+ }
+ };
+
+ const onBeforeUnload = () => {
+ flush("unload");
+ };
+
+ document.addEventListener("visibilitychange", onVisibilityChange);
+ window.addEventListener("beforeunload", onBeforeUnload);
+
+ return () => {
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+ window.removeEventListener("beforeunload", onBeforeUnload);
+ flush("unmount");
+ };
+ }, [id]);
+
+ const generateLessonsFromModule = (module) => {
+ const lessons = [];
+ let order = 0;
+
+ if (module.learningMaterials?.videos) {
+ module.learningMaterials.videos.forEach((video, index) => {
+ lessons.push({
+ _id: `${module._id}-video-${index}`,
+ type: "video",
+ title: video.title || `Video ${index + 1}`,
+ videoUrl: video.link,
+ order: order++,
+ duration: video.duration || "",
+ });
+ });
+ }
+
+ if (module.learningMaterials?.readings) {
+ module.learningMaterials.readings.forEach((reading, index) => {
+ lessons.push({
+ _id: `${module._id}-reading-${index}`,
+ type: "reading",
+ title: reading.title || `Reading ${index + 1}`,
+ order: order++,
+ readingData: reading,
+ });
+ });
+ }
+
+ if (module.learningMaterials?.podcasts) {
+ module.learningMaterials.podcasts.forEach((podcast, index) => {
+ lessons.push({
+ _id: `${module._id}-podcast-${index}`,
+ type: "podcast",
+ title: podcast.title || `Podcast ${index + 1}`,
+ order: order++,
+ podcastData: podcast,
+ });
+ });
+ }
+
+ if (module.assignment) {
+ lessons.push({
+ _id: `${module._id}-assignment`,
+ type: "assignment",
+ title: module.assignment.title,
+ order: order++,
+ assignmentType: "both",
+ instructions: module.assignment.purpose,
+ assignmentData: module.assignment,
+ });
+ }
+
+ if (module.lessons && module.lessons.length > 0) {
+ module.lessons.forEach((lesson) => {
+ lessons.push(lesson);
+ });
+ }
+
+ return lessons;
+ };
+
+ useEffect(() => {
+ const fetchCourseAndProgress = async () => {
+ try {
+ setLoading(true);
+
+ const courseResponse = await fetch(`/api/academy/courses/${id}`);
+ if (!courseResponse.ok) {
+ throw new Error("Course not found");
+ }
+
+ const courseData = await courseResponse.json();
+ courseData.modules = courseData.modules.map((module) => ({
+ ...module,
+ lessons: generateLessonsFromModule(module),
+ }));
+ setCourse(courseData);
+
+ const progressResponse = await fetch(`/api/courses/${id}/progress`, {
+ credentials: "include",
+ });
+ const progressData = await progressResponse.json();
+ setProgress(progressData);
+
+ if (progressData.currentLessonId && progressData.currentModuleId) {
+ const module = courseData.modules.find(
+ (item) => item._id === progressData.currentModuleId
+ );
+ const lesson = module?.lessons.find(
+ (item) => item._id === progressData.currentLessonId
+ );
+
+ if (module && lesson) {
+ setCurrentModule(module);
+ setCurrentLesson(lesson);
+ } else {
+ startFromBeginning(courseData);
+ }
+ } else {
+ startFromBeginning(courseData);
+ }
+ } catch (error) {
+ console.error("Error loading course:", error);
+ alert("Failed to load course");
+ navigate("/academy/my-courses");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchCourseAndProgress();
+ }, [id, navigate]);
+
+ const startFromBeginning = (courseData) => {
+ if (courseData.modules.length > 0 && courseData.modules[0].lessons.length > 0) {
+ setCurrentModule(courseData.modules[0]);
+ setCurrentLesson(courseData.modules[0].lessons[0]);
+ }
+ };
+
+ const updatePosition = async (moduleId, lessonId) => {
+ try {
+ await fetch(`/api/courses/${id}/progress/position`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({ moduleId, lessonId }),
+ });
+ } catch (error) {
+ console.error("Error updating position:", error);
+ }
+ };
+
+ const getAllLessonsInOrder = () => {
+ const lessons = [];
+ course.modules.forEach((module) => {
+ module.lessons.forEach((lesson) => {
+ lessons.push({ module, lesson });
+ });
+ });
+ return lessons;
+ };
+
+ const handleLessonSelect = (module, lesson) => {
+ const allLessons = getAllLessonsInOrder();
+ const currentLessonIndex = allLessons.findIndex(
+ (item) => item.lesson._id === lesson._id
+ );
+
+ if (currentLessonIndex > 0) {
+ const previousLessons = allLessons.slice(0, currentLessonIndex);
+ const allPreviousCompleted = previousLessons.every((item) =>
+ progress.completedLessons?.includes(item.lesson._id)
+ );
+
+ if (!allPreviousCompleted) {
+ alert("Please complete previous lessons first");
+ return;
+ }
+ }
+
+ setCurrentModule(module);
+ setCurrentLesson(lesson);
+ updatePosition(module._id, lesson._id);
+ };
+
+ const handleNext = () => {
+ const allLessons = getAllLessonsInOrder();
+ const currentIndex = allLessons.findIndex(
+ (item) => item.lesson._id === currentLesson._id
+ );
+
+ if (currentIndex < allLessons.length - 1) {
+ const next = allLessons[currentIndex + 1];
+ setCurrentModule(next.module);
+ setCurrentLesson(next.lesson);
+ updatePosition(next.module._id, next.lesson._id);
+ return;
+ }
+
+ if (course.finalTest && course.finalTest.questions.length > 0) {
+ setShowFinalTest(true);
+ } else {
+ setShowCompleteModal(true);
+ }
+ };
+
+ const handlePrevious = () => {
+ const allLessons = getAllLessonsInOrder();
+ const currentIndex = allLessons.findIndex(
+ (item) => item.lesson._id === currentLesson._id
+ );
+
+ if (currentIndex > 0) {
+ const previous = allLessons[currentIndex - 1];
+ setCurrentModule(previous.module);
+ setCurrentLesson(previous.lesson);
+ updatePosition(previous.module._id, previous.lesson._id);
+ }
+ };
+
+ const handleLessonComplete = async () => {
+ try {
+ const response = await fetch(
+ `/api/courses/${id}/progress/lesson/${currentLesson._id}/complete`,
+ {
+ method: "POST",
+ credentials: "include",
+ }
+ );
+
+ const data = await response.json();
+ setProgress(data.progress);
+ handleNext();
+ } catch (error) {
+ console.error("Error marking lesson complete:", error);
+ }
+ };
+
+ const handleTestComplete = (passed) => {
+ if (passed) {
+ setShowCompleteModal(true);
+ }
+ setShowFinalTest(false);
+ };
+
+ const handleExit = () => {
+ navigate("/academy/my-courses");
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!course) {
+ return (
+
+ );
+ }
+
+ const allLessons = getAllLessonsInOrder();
+ const currentIndex = allLessons.findIndex(
+ (item) => item.lesson._id === currentLesson?._id
+ );
+
+ return (
+
+
+
+ Exit Course
+
+
{course.title}
+
+ {progress?.completedLessons?.length || 0} / {allLessons.length} Complete
+
+ {progress?.progressPercentage || 0}%
+
+
+
+
+
+
+
+
+ {showFinalTest ? (
+
+ ) : currentLesson ? (
+ <>
+ {currentLesson.type === "video" && (
+
+ )}
+
+ {currentLesson.type === "assignment" && (
+
+ )}
+
+ {currentLesson.type === "reading" && (
+
+ )}
+
+ {currentLesson.type === "podcast" && (
+
+ )}
+
+ {currentLesson.type === "quiz" && (
+
+ )}
+
+
+
+ Previous
+
+
+
+ {currentIndex === allLessons.length - 1
+ ? "Go to Final Test ->"
+ : "Next Lesson"}
+
+
+ >
+ ) : (
+
+ Select a lesson from the sidebar to begin
+
+ )}
+
+
+
+ {showCompleteModal && (
+
{
+ setShowCompleteModal(false);
+ navigate("/academy/my-courses");
+ }}
+ />
+ )}
+
+ );
+}
+
+export default CourseLearning;
diff --git a/frontend/src/pages/Academy/Courses/CourseSidebar.jsx b/frontend/src/pages/Academy/Courses/CourseSidebar.jsx
new file mode 100644
index 0000000..1e1c625
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/CourseSidebar.jsx
@@ -0,0 +1,143 @@
+import React, { useState } from "react";
+import "./CourseLearning.css";
+
+function CourseSidebar({ course, progress, currentLesson, currentModule, onLessonSelect }) {
+ const [expandedModules, setExpandedModules] = useState(
+ course.modules.reduce((acc, module, index) => {
+ acc[module._id] = index === 0;
+ return acc;
+ }, {})
+ );
+
+ const toggleModule = (moduleId) => {
+ setExpandedModules((prev) => ({
+ ...prev,
+ [moduleId]: !prev[moduleId],
+ }));
+ };
+
+ const isLessonCompleted = (lessonId) => {
+ return progress?.completedLessons?.includes(lessonId);
+ };
+
+ const isLessonAccessible = (module, lesson) => {
+ const moduleIndex = course.modules.findIndex((item) => item._id === module._id);
+ const lessonIndex = module.lessons.findIndex((item) => item._id === lesson._id);
+
+ if (moduleIndex === 0 && lessonIndex === 0) {
+ return true;
+ }
+
+ const priorLessonIds = [];
+ for (let i = 0; i < course.modules.length; i += 1) {
+ for (let j = 0; j < course.modules[i].lessons.length; j += 1) {
+ if (i < moduleIndex || (i === moduleIndex && j < lessonIndex)) {
+ priorLessonIds.push(course.modules[i].lessons[j]._id);
+ }
+ }
+ }
+
+ return priorLessonIds.every((id) => isLessonCompleted(id));
+ };
+
+ const getLessonLabel = (type) => {
+ switch (type) {
+ case "video":
+ return "[V]";
+ case "assignment":
+ return "[A]";
+ case "quiz":
+ return "[Q]";
+ case "podcast":
+ return "[P]";
+ case "reading":
+ default:
+ return "[L]";
+ }
+ };
+
+ return (
+
+
+
Course Content
+
+
+
+ {course.modules.map((module, moduleIndex) => (
+
+
toggleModule(module._id)}
+ >
+
+ Module {moduleIndex + 1}
+ {module.title}
+
+
+ {expandedModules[module._id] ? "-" : "+"}
+
+
+
+ {expandedModules[module._id] && (
+
+ {module.lessons.map((lesson) => {
+ const isCompleted = isLessonCompleted(lesson._id);
+ const isAccessible = isLessonAccessible(module, lesson);
+ const isCurrent = currentLesson?._id === lesson._id;
+
+ return (
+
{
+ if (isAccessible) {
+ onLessonSelect(module, lesson);
+ }
+ }}
+ >
+
+
+ {isCompleted ? "[Done]" : getLessonLabel(lesson.type)}
+
+
+
{lesson.title}
+
+ {lesson.duration && (
+ {lesson.duration}
+ )}
+ {lesson.type}
+
+
+
+ {!isAccessible && (
+
Locked
+ )}
+
+ );
+ })}
+
+ )}
+
+ ))}
+
+ {course.finalTest && course.finalTest.questions.length > 0 && (
+
+
+
+ Final Test
+
+
Required
+
+
+ )}
+
+
+ );
+}
+
+export default CourseSidebar;
diff --git a/frontend/src/pages/Academy/Courses/CreateCourse.css b/frontend/src/pages/Academy/Courses/CreateCourse.css
new file mode 100644
index 0000000..848d1ac
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/CreateCourse.css
@@ -0,0 +1,679 @@
+.create-course-page {
+ min-height: 100vh;
+ background: #f8f9fa;
+ padding: 2rem 0;
+}
+
+.create-course-container {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 0 2rem;
+}
+
+.back-button {
+ background: none;
+ border: none;
+ color: #4F46E5;
+ font-size: 1rem;
+ cursor: pointer;
+ margin-bottom: 1.5rem;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: color 0.2s;
+}
+
+.back-button:hover {
+ color: #4338ca;
+}
+
+.create-course-container h1 {
+ font-size: 2.5rem;
+ color: #1a1a1a;
+ margin: 0 0 0.5rem 0;
+}
+
+.page-subtitle {
+ color: #666;
+ font-size: 1rem;
+ margin: 0;
+}
+
+/* Steps Indicator */
+.steps-indicator {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 3rem;
+ position: relative;
+}
+
+.steps-indicator::before {
+ content: '';
+ position: absolute;
+ top: 20px;
+ left: 40px;
+ right: 40px;
+ height: 2px;
+ background: #e0e0e0;
+ z-index: 0;
+}
+
+.step {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ position: relative;
+ z-index: 1;
+ flex: 1;
+}
+
+.step-number {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: #fff;
+ border: 2px solid #e0e0e0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ color: #999;
+ transition: all 0.3s ease;
+}
+
+.step.active .step-number {
+ background: #4F46E5;
+ border-color: #4F46E5;
+ color: white;
+}
+
+.step.completed .step-number {
+ background: #10b981;
+ border-color: #10b981;
+ color: white;
+}
+
+.step-label {
+ font-size: 0.875rem;
+ color: #666;
+ text-align: center;
+ font-weight: 500;
+}
+
+.step.active .step-label {
+ color: #4F46E5;
+ font-weight: 600;
+}
+
+/* Form Container */
+.form-container {
+ background: white;
+ border-radius: 12px;
+ padding: 2.5rem;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ margin-bottom: 2rem;
+}
+
+.form-section {
+ animation: fadeIn 0.3s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.form-section h2 {
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: #1a1a1a;
+ margin: 0 0 0.5rem 0;
+}
+
+.section-subtitle {
+ color: #666;
+ font-size: 0.95rem;
+ margin: 0 0 2rem 0;
+}
+
+/* Form Groups */
+.form-group {
+ margin-bottom: 1.5rem;
+}
+
+.form-group label {
+ display: block;
+ font-weight: 500;
+ color: #333;
+ margin-bottom: 0.5rem;
+ font-size: 0.95rem;
+}
+
+.form-group input[type="text"],
+.form-group input[type="number"],
+.form-group input[type="url"],
+.form-group input[type="email"],
+.form-group textarea,
+.form-group select {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ font-size: 0.95rem;
+ transition: border-color 0.2s;
+ font-family: inherit;
+}
+
+.form-group input:focus,
+.form-group textarea:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #4F46E5;
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+.form-group textarea {
+ resize: vertical;
+ min-height: 100px;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.5rem;
+}
+
+.form-group input[type="color"] {
+ width: 100px;
+ height: 45px;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ cursor: pointer;
+}
+
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ font-weight: 400 !important;
+}
+
+.checkbox-label input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ cursor: pointer;
+}
+
+/* Buttons */
+.add-button,
+.primary-button,
+.secondary-button,
+.save-button,
+.cancel-button {
+ padding: 0.75rem 1.5rem;
+ border-radius: 8px;
+ font-weight: 500;
+ font-size: 0.95rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ border: none;
+}
+
+.add-button,
+.primary-button,
+.save-button {
+ background: #4F46E5;
+ color: white;
+}
+
+.add-button:hover,
+.primary-button:hover,
+.save-button:hover {
+ background: #4338CA;
+}
+
+.secondary-button,
+.cancel-button {
+ background: white;
+ color: #666;
+ border: 1px solid #ddd;
+}
+
+.secondary-button:hover,
+.cancel-button:hover {
+ background: #f8f9fa;
+ border-color: #ccc;
+}
+
+.secondary-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.remove-button {
+ background: #ef4444;
+ color: white;
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.875rem;
+ transition: background 0.2s;
+}
+
+.remove-button:hover {
+ background: #dc2626;
+}
+
+.remove-button-small {
+ background: transparent;
+ color: #ef4444;
+ border: none;
+ cursor: pointer;
+ font-size: 1.5rem;
+ line-height: 1;
+ padding: 0;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ transition: background 0.2s;
+}
+
+.remove-button-small:hover {
+ background: rgba(239, 68, 68, 0.1);
+}
+
+/* Form Actions */
+.form-actions {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-top: 2rem;
+}
+
+/* Add Item Container */
+.add-item-container {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.add-item-container input {
+ flex: 1;
+}
+
+/* Items List */
+.items-list,
+.outcomes-list,
+.materials-list,
+.parts-list,
+.criteria-list {
+ margin-top: 1rem;
+}
+
+.list-item,
+.outcome-item,
+.material-item,
+.part-item,
+.criterion-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ background: #f8f9fa;
+ border-radius: 8px;
+ margin-bottom: 0.75rem;
+}
+
+.outcome-item {
+ align-items: flex-start;
+ gap: 0.75rem;
+}
+
+.outcome-number {
+ font-weight: 600;
+ color: #4F46E5;
+ flex-shrink: 0;
+}
+
+.outcome-text {
+ flex: 1;
+}
+
+/* Empty State */
+.empty-state {
+ text-align: center;
+ color: #999;
+ padding: 2rem;
+ background: #f8f9fa;
+ border-radius: 8px;
+ font-size: 0.95rem;
+}
+
+/* Module Card */
+.modules-list {
+ margin-top: 2rem;
+}
+
+.modules-list h3 {
+ font-size: 1.25rem;
+ margin-bottom: 1rem;
+ color: #1a1a1a;
+}
+
+.module-card {
+ background: #f8f9fa;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 1.5rem;
+ margin-bottom: 1rem;
+}
+
+.module-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 1rem;
+}
+
+.module-header h4 {
+ margin: 0 0 0.5rem 0;
+ color: #1a1a1a;
+ font-size: 1.1rem;
+}
+
+.module-overview {
+ color: #666;
+ font-size: 0.9rem;
+ margin: 0;
+}
+
+.module-details {
+ display: flex;
+ gap: 2rem;
+ font-size: 0.875rem;
+ color: #666;
+ margin-top: 1rem;
+}
+
+.detail-section strong {
+ color: #333;
+}
+
+/* Badge Preview */
+.badge-preview {
+ margin-top: 2rem;
+ padding: 2rem;
+ background: #f8f9fa;
+ border-radius: 8px;
+ text-align: center;
+}
+
+.badge-preview h4 {
+ margin: 0 0 1.5rem 0;
+ color: #1a1a1a;
+}
+
+.preview-badge {
+ width: 120px;
+ height: 120px;
+ border-radius: 50%;
+ margin: 0 auto 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+}
+
+.preview-badge img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 50%;
+}
+
+.badge-emoji {
+ font-size: 3rem;
+}
+
+.badge-preview p {
+ margin: 0.5rem 0;
+ color: #666;
+}
+
+.badge-preview p strong {
+ color: #1a1a1a;
+ font-size: 1.1rem;
+}
+
+/* Question Builder */
+.question-builder {
+ background: #f8f9fa;
+ padding: 1.5rem;
+ border-radius: 8px;
+ margin-top: 1.5rem;
+}
+
+.question-builder h4,
+.question-builder h5 {
+ margin: 0 0 1rem 0;
+ color: #1a1a1a;
+}
+
+/* Modules Section Specific */
+.modules-section {
+ padding: 0;
+}
+
+.modules-section .form-section {
+ padding: 0;
+}
+
+/* Helper Text */
+.helper-text {
+ color: #666;
+ font-size: 0.9rem;
+ margin-bottom: 1rem;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .create-course-container {
+ padding: 0 1rem;
+ }
+
+ .form-container {
+ padding: 1.5rem;
+ }
+
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+
+ .steps-indicator {
+ overflow-x: auto;
+ padding-bottom: 1rem;
+ }
+
+ .step-label {
+ font-size: 0.75rem;
+ }
+
+ .module-details {
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+}
+
+/* Assignment Question Builder Styles */
+.assignment-question-builder {
+ background: #f8f9fa;
+ padding: 20px;
+ border-radius: 8px;
+ margin-bottom: 20px;
+}
+
+.question-type-select {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-size: 14px;
+}
+
+.matching-pairs-container {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.matching-pair-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.matching-input {
+ flex: 1;
+ padding: 8px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+}
+
+.matching-arrow {
+ font-size: 18px;
+ color: #666;
+}
+
+.remove-button-small {
+ background: #ff4444;
+ color: white;
+ border: none;
+ padding: 6px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.remove-button-small:hover {
+ background: #cc0000;
+}
+
+.assignment-questions-list {
+ margin-top: 20px;
+ padding: 20px;
+ background: white;
+ border-radius: 8px;
+ border: 1px solid #ddd;
+}
+
+.assignment-question-item {
+ padding: 15px;
+ background: #f8f9fa;
+ border-radius: 6px;
+ margin-bottom: 10px;
+}
+
+.question-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.question-points {
+ color: #4F46E5;
+ font-weight: 600;
+}
+
+.question-text {
+ color: #333;
+ margin: 0;
+}
+
+.assignment-actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+.assignment-preview {
+ padding: 15px;
+ background: #e8f5e9;
+ border: 1px solid #4caf50;
+ border-radius: 6px;
+ margin-top: 10px;
+}
+
+.assignment-preview-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.add-button-inline {
+ padding: 8px 16px;
+ background: #4F46E5;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.add-button-inline:hover {
+ background: #3730a3;
+}
+
+.material-type-section {
+ margin-bottom: 20px;
+ padding: 15px;
+ background: #f8f9fa;
+ border-radius: 8px;
+}
+
+.material-type-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+.material-item {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 10px;
+ align-items: center;
+}
+
+.material-item input {
+ flex: 1;
+ padding: 8px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+}
+
+.add-button-small {
+ padding: 6px 12px;
+ background: #4F46E5;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 13px;
+}
+
+.add-button-small:hover {
+ background: #3730a3;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/CreateCourse.jsx b/frontend/src/pages/Academy/Courses/CreateCourse.jsx
index 427027f..175d684 100644
--- a/frontend/src/pages/Academy/Courses/CreateCourse.jsx
+++ b/frontend/src/pages/Academy/Courses/CreateCourse.jsx
@@ -1,310 +1,500 @@
-/* global process */
-import React, { useState } from 'react';
-import { useNavigate } from 'react-router-dom';
+import React, { useState, useRef, useEffect } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
import { FiArrowLeft } from 'react-icons/fi';
+import './CreateCourse.css';
+import ImageUpload from '../../../components/ImageUpload';
+import ModuleBuilder from './ModuleBuilder';
function CreateCourse() {
const navigate = useNavigate();
- const [currentStep, setCurrentStep] = useState('basic-info');
- const [learningPoint, setLearningPoint] = useState('');
- const [modulePoint, setModulePoint] = useState('');
- const [currentModule, setCurrentModule] = useState({
- title: '',
- description: '',
- videoUrl: '',
- articleContent: '',
- pdfUrl: '',
- learningPoints: [],
- duration: '',
- thumbnail: '',
- });
+ const { courseId } = useParams();
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [currentStep, setCurrentStep] = useState(1);
const [courseData, setCourseData] = useState({
- // basic info
+ // Basic Info
title: '',
- description: '',
+ overview: '',
duration: '',
difficulty: 'Beginner',
category: '',
thumbnail: '',
isLiteVersion: false,
- // instructor
- instructorName: '',
- instructorTitle: '',
- instructorBio: '',
- instructorAvatar: '',
-
- // pricing
- priceAmount: '',
- priceCurrency: 'USD',
- pricingType: 'one-time',
-
- // subscription info
- isSubscriptionCourse: false,
- subscriptionTier: '',
+ // Instructor
+ instructor: {
+ name: '',
+ title: '',
+ bio: '',
+ avatar: ''
+ },
+
+ // Pricing
+ pricing: {
+ amount: 0,
+ currency: 'USD',
+ type: 'one-time'
+ },
+
+ // Modules (new structure)
+ modules: [],
- // content
- learningPoints: [],
+ // Final Test
+ finalTest: {
+ title: 'Final Test',
+ description: '',
+ passingScore: 70,
+ timeLimit: 0,
+ questions: []
+ },
+
+ // Badge
+ badge: {
+ name: '',
+ description: '',
+ color: '#4F46E5',
+ imageUrl: ''
+ }
+ });
- // modules
- modules: [],
+ const [currentModule, setCurrentModule] = useState({
+ title: '',
+ overview: '',
+ learningOutcomes: [],
+ learningMaterials: {
+ readings: [],
+ podcasts: [],
+ videos: []
+ },
+ assignment: null
});
- const handleBack = () => {
- navigate('/academy/create');
- };
+ const [newOutcome, setNewOutcome] = useState('');
+ const [currentQuestion, setCurrentQuestion] = useState({
+ question: '',
+ options: ['', '', '', ''],
+ correctAnswer: '',
+ points: 1
+ });
- const handleInputChange = (e) => {
- const { name, value, type, checked } = e.target;
- setCourseData({
- ...courseData,
- [name]: type === 'checkbox' ? checked : value,
- });
- };
+ const steps = ['Basic Info', 'Instructor', 'Pricing', 'Modules', 'Final Test', 'Badge'];
- const handleAddLearningPoint = () => {
- if (learningPoint.trim()) {
+ const fetchCourseData = async () => {
+ try {
+ const response = await fetch(`/api/academy/courses/${courseId}`, {
+ credentials: 'include'
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch course');
+ }
+
+ const course = await response.json();
+
+ // Pre-populate the form with existing data
setCourseData({
- ...courseData,
- learningPoints: [...courseData.learningPoints, learningPoint.trim()],
+ title: course.title || '',
+ overview: course.description || '',
+ duration: course.duration || '',
+ difficulty: course.difficulty || 'Beginner',
+ category: course.category || '',
+ thumbnail: course.thumbnail || '',
+ isLiteVersion: course.isLiteVersion || false,
+
+ instructor: {
+ _id: course.instructor?._id,
+ name: course.instructor?.name || '',
+ title: course.instructor?.title || '',
+ bio: course.instructor?.bio || '',
+ avatar: course.instructor?.avatar || '',
+ email: course.instructor?.email || ''
+ },
+
+ pricing: {
+ amount: course.pricing?.amount || 0,
+ currency: course.pricing?.currency || 'USD',
+ type: course.pricing?.type || 'one-time'
+ },
+
+ modules: course.modules || [],
+
+ finalTest: course.finalTest || {
+ title: 'Final Test',
+ description: '',
+ passingScore: 70,
+ timeLimit: 0,
+ questions: []
+ },
+
+ badge: course.badge || {
+ name: '',
+ description: '',
+ color: '#4F46E5',
+ imageUrl: ''
+ }
});
- setLearningPoint('');
+
+ setIsEditMode(true);
+
+ } catch (err) {
+ console.error('Error fetching course:', err);
+ alert('Failed to load course for editing');
+ navigate('/instructor/dashboard');
}
};
- const handleRemoveLearningPoint = (index) => {
- setCourseData({
- ...courseData,
- learningPoints: courseData.learningPoints.filter((_, i) => i !== index),
- });
+ // Fetch course data if editing
+useEffect(() => {
+ if (courseId) {
+ fetchCourseData();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+}, [courseId]);
+
+ const handleNext = () => {
+ if (currentStep < steps.length) setCurrentStep(currentStep + 1);
};
- const handleModuleInputChange = (e) => {
- const { name, value } = e.target;
- setCurrentModule({
- ...currentModule,
- [name]: value,
- });
+ const handlePrevious = () => {
+ if (currentStep > 1) setCurrentStep(currentStep - 1);
};
- const handleAddModulePoint = () => {
- if (modulePoint.trim()) {
- setCurrentModule({
- ...currentModule,
- learningPoints: [...currentModule.learningPoints, modulePoint.trim()],
- });
- setModulePoint('');
+ const handleInputChange = (section, field, value) => {
+ if (section) {
+ setCourseData(prev => ({
+ ...prev,
+ [section]: { ...prev[section], [field]: value }
+ }));
+ } else {
+ setCourseData(prev => ({ ...prev, [field]: value }));
+ }
+ };
+
+ const addLearningOutcome = () => {
+ if (newOutcome.trim()) {
+ setCurrentModule(prev => ({
+ ...prev,
+ learningOutcomes: [...prev.learningOutcomes, newOutcome]
+ }));
+ setNewOutcome('');
}
};
- const handleRemoveModulePoint = (index) => {
+ const removeLearningOutcome = (index) => {
+ setCurrentModule(prev => ({
+ ...prev,
+ learningOutcomes: prev.learningOutcomes.filter((_, i) => i !== index)
+ }));
+ };
+
+ const handleModuleSave = (module) => {
+ setCourseData(prev => ({
+ ...prev,
+ modules: [...prev.modules, { ...module, order: prev.modules.length }]
+ }));
+
+ // Reset current module
setCurrentModule({
- ...currentModule,
- learningPoints: currentModule.learningPoints.filter((_, i) => i !== index),
+ title: '',
+ overview: '',
+ learningOutcomes: [],
+ learningMaterials: {
+ readings: [],
+ podcasts: [],
+ videos: []
+ },
+ assignment: null
});
};
- const handleAddModule = () => {
- if (!currentModule.title.trim()) {
- alert('Please enter a module title');
+ const removeModule = (index) => {
+ setCourseData(prev => ({
+ ...prev,
+ modules: prev.modules.filter((_, i) => i !== index)
+ }));
+ };
+
+ const addQuestionToTest = () => {
+ if (!currentQuestion.question) {
+ alert('Please enter a question');
+ return;
+ }
+ if (currentQuestion.options.some(opt => !opt.trim())) {
+ alert('Please fill in all answer options');
+ return;
+ }
+ if (currentQuestion.correctAnswer === '') {
+ alert('Please specify the correct answer');
return;
}
- setCourseData({
- ...courseData,
- modules: [...courseData.modules, { ...currentModule, order: courseData.modules.length }],
- });
+ setCourseData(prev => ({
+ ...prev,
+ finalTest: {
+ ...prev.finalTest,
+ questions: [...prev.finalTest.questions, {
+ question: currentQuestion.question,
+ options: currentQuestion.options,
+ correctAnswer: parseInt(currentQuestion.correctAnswer),
+ points: currentQuestion.points
+ }]
+ }
+ }));
- // Reset current module
- setCurrentModule({
- title: '',
- description: '',
- videoUrl: '',
- articleContent: '',
- pdfUrl: '',
- learningPoints: [],
- duration: '',
- thumbnail: '',
+ setCurrentQuestion({
+ question: '',
+ options: ['', '', '', ''],
+ correctAnswer: '',
+ points: 1
});
- setModulePoint('');
};
- const handleRemoveModule = (index) => {
- setCourseData({
- ...courseData,
- modules: courseData.modules.filter((_, i) => i !== index),
- });
+ const removeQuestionFromTest = (index) => {
+ setCourseData(prev => ({
+ ...prev,
+ finalTest: {
+ ...prev.finalTest,
+ questions: prev.finalTest.questions.filter((_, i) => i !== index)
+ }
+ }));
};
- const validateForm = () => {
- if (!courseData.title.trim()) {
- alert('Please enter a course title');
- setCurrentStep('basic-info');
- return false;
+const handleSubmit = async () => {
+ try {
+ if (!courseData.title || !courseData.overview) {
+ alert('Please fill in course title and overview');
+ return;
}
- if (!courseData.description.trim()) {
- alert('Please enter a course description');
- setCurrentStep('basic-info');
- return false;
+ if (!courseData.instructor.name) {
+ alert('Please fill in instructor information');
+ return;
}
- if (!courseData.instructorName.trim()) {
- alert('Please enter an instructor name');
- setCurrentStep('instructor');
- return false;
+ if (courseData.modules.length === 0) {
+ alert('Please add at least one module');
+ return;
}
- return true;
- };
- const handleCreateCourse = async () => {
- if (!validateForm()) {
+ // Transform data to match backend schema
+ const backendData = {
+ title: courseData.title,
+ description: courseData.overview,
+ duration: courseData.duration,
+ difficulty: courseData.difficulty,
+ category: courseData.category,
+ thumbnail: courseData.thumbnail,
+ isLiteVersion: courseData.isLiteVersion,
+
+ instructor: courseData.instructor,
+ pricing: courseData.pricing,
+
+ // Include ALL the new fields
+ modules: courseData.modules.map(module => ({
+ title: module.title,
+ description: module.overview || module.description,
+ order: module.order,
+ learningOutcomes: module.learningOutcomes,
+ learningMaterials: module.learningMaterials,
+ assignment: module.assignment,
+ lessons: module.lessons || []
+ })),
+
+ finalTest: courseData.finalTest.questions.length > 0 ? courseData.finalTest : null,
+ badge: courseData.badge
+ };
+
+ console.log('Sending course data:', JSON.stringify(backendData, null, 2));
+
+ // Determine if creating or updating
+ const url = isEditMode ? `/api/academy/courses/${courseId}` : '/api/academy/courses';
+ const method = isEditMode ? 'PUT' : 'POST';
+
+ const res = await fetch(url, {
+ method: method,
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(backendData)
+ });
+
+ const responseData = await res.json();
+
+ if (!res.ok) {
+ console.error('Server error response:', responseData);
+ alert(`Failed to ${isEditMode ? 'update' : 'create'} course: ${responseData.error || 'Unknown error'}`);
return;
}
+ alert(`Course ${isEditMode ? 'updated' : 'created'} successfully!`);
+ navigate('/instructor/dashboard');
+
+ } catch (err) {
+ console.error('Error saving course:', err);
+ alert(`Failed to ${isEditMode ? 'update' : 'create'} course: ${err.message}`);
+ }
+};
+
+ // Camera
+ const videoRef = useRef(null);
+ const canvasRef = useRef(null);
+
+ const [cameraOpen, setCameraOpen] = useState(false);
+ const [cameraError, setCameraError] = useState('');
+ const [cameraStream, setCameraStream] = useState(null);
+ const [facingMode, setFacingMode] = useState('user'); // 'user' | 'environment'
+
+ const startCamera = async () => {
+ setCameraError('');
+
try {
- const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:5000';
- const response = await fetch(`${apiUrl}/api/academy/courses`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- title: courseData.title,
- description: courseData.description,
- duration: courseData.duration,
- difficulty: courseData.difficulty,
- category: courseData.category,
- thumbnail: courseData.thumbnail,
- isLiteVersion: courseData.isLiteVersion,
-
- instructor: {
- name: courseData.instructorName,
- title: courseData.instructorTitle,
- bio: courseData.instructorBio,
- avatar: courseData.instructorAvatar,
- },
-
- pricing: {
- amount: courseData.priceAmount ? Number(courseData.priceAmount) : 0,
- currency: courseData.priceCurrency,
- type: courseData.pricingType,
- },
-
- subscription: {
- isSubscriptionCourse: courseData.isSubscriptionCourse,
- tier: courseData.subscriptionTier,
- },
-
- learningPoints: courseData.learningPoints,
- modules: courseData.modules,
- }),
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode },
+ audio: false
});
- const data = await response.json();
+ setCameraStream(stream);
+ setCameraOpen(true);
- if (response.ok) {
- alert('Course created successfully!');
- navigate('/academy/courses');
- } else {
- alert(`Failed to create course: ${data.error || data.message || 'Unknown error'}`);
+ if (videoRef.current) {
+ videoRef.current.srcObject = stream;
+ await videoRef.current.play();
}
- } catch (error) {
- console.error('Error creating course:', error);
- alert('An error occurred while creating the course. Please try again.');
+ } catch (err) {
+ const msg =
+ err?.name === 'NotAllowedError'
+ ? 'Camera permission was denied.'
+ : err?.name === 'NotFoundError'
+ ? 'No camera device found.'
+ : 'Could not access the camera.';
+ setCameraError(msg);
+ setCameraOpen(false);
}
};
- const handleCancel = () => {
- if (window.confirm('Are you sure you want to cancel? All unsaved changes will be lost.')) {
- navigate('/academy/create');
+ const stopCamera = () => {
+ if (cameraStream) {
+ cameraStream.getTracks().forEach((t) => t.stop());
}
+ setCameraStream(null);
+ setCameraOpen(false);
};
- const steps = [
- { id: 'basic-info', label: 'Basic Info' },
- { id: 'instructor', label: 'Instructor' },
- { id: 'pricing', label: 'Pricing' },
- { id: 'content', label: 'Content' },
- { id: 'modules', label: 'Modules' },
- ];
+ const captureInstructorAvatar = async () => {
+ const video = videoRef.current;
+ const canvas = canvasRef.current;
+ if (!video || !canvas) return;
- return (
-
-
-
- Back
-
+ const w = video.videoWidth || 640;
+ const h = video.videoHeight || 480;
-
Create New Course
-
Fill in the details to create a new course
+ canvas.width = w;
+ canvas.height = h;
-
- {steps.map((step) => (
- setCurrentStep(step.id)}
- >
- {step.label}
-
- ))}
-
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(video, 0, 0, w, h);
+
+ canvas.toBlob(async (blob) => {
+ try {
+ if (!blob) throw new Error("Failed to capture image");
- {currentStep === 'basic-info' && (
-
-
Course Information
-
Basic details about your course
+ const form = new FormData();
+ form.append("image", blob, "instructor-avatar.png");
-
-
- Course Title *
-
+ const res = await fetch("/api/upload/image", {
+ method: "POST",
+ body: form,
+ credentials: "include"
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.error || "Upload failed");
+ }
+
+ // Store Cloudinary URL (not base64)
+ handleInputChange("instructor", "avatar", data.url);
+
+ stopCamera();
+ } catch (err) {
+ console.error("Avatar upload failed:", err);
+ setCameraError(err.message || "Failed to upload image");
+ }
+ }, "image/png", 0.92);
+};
+
+ // If camera is open and we switch cameras, restart stream
+useEffect(() => {
+ if (!cameraOpen) return;
+ if (!cameraStream) return;
+ if (!videoRef.current) return;
+
+ const video = videoRef.current;
+ video.srcObject = cameraStream;
+
+ const playVideo = async () => {
+ try {
+ await video.play();
+ } catch (err) {
+ console.warn('Video play error:', err);
+ }
+ };
+
+ video.onloadedmetadata = playVideo;
+ playVideo();
+
+}, [cameraOpen, cameraStream]);
+
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => stopCamera();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+
+ const renderStepContent = () => {
+ switch (currentStep) {
+ case 1:
+ return (
+
+
Course Information
+
Basic details about your course
+
+
+ Course Title *
handleInputChange(null, 'title', e.target.value)}
+ placeholder="e.g., Branding Yourself in Freelancing"
/>
-
-
- Description *
-
+
+ Course Overview *
-
-
-
Duration
+
+
+ Duration
handleInputChange(null, 'duration', e.target.value)}
+ placeholder="e.g., 4 weeks"
/>
-
-
Difficulty Level
+
+ Difficulty Level
handleInputChange(null, 'difficulty', e.target.value)}
>
Beginner
Intermediate
@@ -313,435 +503,483 @@ function CreateCourse() {
-
-
Category
+
+ Category
handleInputChange(null, 'category', e.target.value)}
+ placeholder="e.g., Digital Marketing, Design, Development"
/>
-
-
Thumbnail URL
-
-
- ⬆
-
-
-
-
- )}
+ );
- {currentStep === 'instructor' && (
-
-
Instructor Information
-
Details about the course instructor
+ case 2:
+ return (
+
+
Instructor Information
+
Details about the course instructor
-
-
- Instructor Name *
-
+
+ Instructor Name *
handleInputChange('instructor', 'name', e.target.value)}
+ placeholder="e.g., Dr. Sarah Johnson"
/>
-
-
Instructor Title/Role
+
+ Instructor Title/Role
handleInputChange('instructor', 'title', e.target.value)}
+ placeholder="e.g., Senior Marketing Consultant"
/>
-
-
Instructor Bio
+
+ Instructor Bio
-
- Instructor Avatar URL
-
-
-
- )}
+
handleInputChange('instructor', 'avatar', url)}
+ label="Instructor Avatar"
+ />
+
+
Or take a photo
+
+ {courseData.instructor.avatar && (
+
+
+
+ )}
- {currentStep === 'pricing' && (
-
-
Pricing Details
-
Set the price for your course. Leave blank for free courses
+
+ {!cameraOpen ? (
+
+ Use Camera
+
+ ) : (
+ <>
+
+ Take Photo
+
+
+
+ Stop
+
+
+ setFacingMode((m) => (m === 'user' ? 'environment' : 'user'))}
+ >
+ Switch Camera
+
+ >
+ )}
+
+
+ {cameraError && (
+
{cameraError}
+ )}
+
+ {cameraOpen && (
+
+
+
+
+ )}
-
+
+ );
+
+ case 3:
+ return (
+
+
Pricing Details
+
Set the price for your course
+
+
+
+ Price Amount
handleInputChange('pricing', 'amount', parseFloat(e.target.value) || 0)}
+ placeholder="e.g., 299"
min="0"
- step="0.01"
- name="priceAmount"
- className="w-full px-4 py-3 border border-light-secondary rounded-sm text-base text-dark-primary font-sans transition-colors duration-300 focus:outline-none focus:border-dark-primary placeholder:text-light-primary"
- placeholder="e.g., 199"
- value={courseData.priceAmount}
- onChange={handleInputChange}
/>
-
-
Currency
+
+ Currency
handleInputChange('pricing', 'currency', e.target.value)}
>
USD
EUR
- CAD
+ GBP
-
-
Pricing Type
+
+ Pricing Type
handleInputChange('pricing', 'type', e.target.value)}
>
One-time payment
Subscription
-
- {courseData.pricingType === 'subscription' && (
-
- (You can extend this to monthly / yearly plans later.)
-
- )}
-
- {courseData.isLiteVersion && (
-
- Note: this course is marked as "Lite", so it may be treated as
- free in the course list.
-
- )}
- )}
-
- {currentStep === 'content' && (
-
-
Course Content
-
Add key learning points for your course
-
-
-
Learning Points
-
- setLearningPoint(e.target.value)}
- onKeyPress={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- handleAddLearningPoint();
- }
- }}
- />
-
- Add Point
-
-
-
+ );
+
+ case 4:
+ return (
+
+
Course Modules
+
Create modules with overview, learning outcomes, materials, and assignments
+
+
- {courseData.learningPoints.length > 0 && (
-
-
Added Learning Points:
-
- {courseData.learningPoints.map((point, index) => (
-
- ✓ {point}
- handleRemoveLearningPoint(index)}
- >
- ✕
+ {courseData.modules.length > 0 && (
+
+
Course Modules ({courseData.modules.length})
+ {courseData.modules.map((module, index) => (
+
+
+
+
Module {index + 1}: {module.title}
+
{module.overview}
+
+
removeModule(index)} className="remove-button">
+ Remove
-
- ))}
-
+
+
+
+
+ Learning Outcomes: {module.learningOutcomes.length}
+
+
+ Materials: {' '}
+ {module.learningMaterials.readings.length} readings, {' '}
+ {module.learningMaterials.podcasts.length} podcasts, {' '}
+ {module.learningMaterials.videos.length} videos
+
+ {module.assignment && (
+
+ Assignment: {module.assignment.title}
+
+ )}
+
+
+ ))}
)}
-
- {courseData.learningPoints.length === 0 && (
- No learning points added yet. Add some to help students understand what they'll learn!
- )}
- )}
+ );
- {currentStep === 'modules' && (
-
-
Course Modules
-
Organize your course into structured modules with content
+ case 5:
+ return (
+
+
Final Test
+
Create a final test to assess student learning
+
+
+ Test Title
+ handleInputChange('finalTest', 'title', e.target.value)}
+ placeholder="Final Test"
+ />
+
-
-
Add New Module
+
+ Test Description
+
-
-
- Module Title *
-
+
+
+ Passing Score (%)
handleInputChange('finalTest', 'passingScore', parseInt(e.target.value))}
+ min="0"
+ max="100"
/>
-
- Module Description
-
+
-
-
Module Content
-
-
- Video URL
-
-
-
-
- Article Content
-
-
+
+
Add Test Questions
-
- PDF/Document URL
-
-
+
+ Question
+ setCurrentQuestion({ ...currentQuestion, question: e.target.value })}
+ placeholder="Enter your question..."
+ />
-
-
-
Duration
+
+ Answer Options
+ {currentQuestion.options.map((option, index) => (
-
-
-
- Thumbnail URL
- {
+ const newOptions = [...currentQuestion.options];
+ newOptions[index] = e.target.value;
+ setCurrentQuestion({ ...currentQuestion, options: newOptions });
+ }}
+ placeholder={`Option ${index + 1}`}
+ style={{ marginBottom: '8px' }}
/>
-
+ ))}
-
-
Module Learning Points
-
- setModulePoint(e.target.value)}
- onKeyPress={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- handleAddModulePoint();
- }
- }}
- />
-
- Add Point
-
-
+
+ Correct Answer
+ setCurrentQuestion({ ...currentQuestion, correctAnswer: e.target.value })}
+ >
+ Select correct answer...
+ {currentQuestion.options.map((option, index) => (
+ {option || `Option ${index + 1}`}
+ ))}
+
- {currentModule.learningPoints.length > 0 && (
-
-
- {currentModule.learningPoints.map((point, index) => (
-
- ✓ {point}
- handleRemoveModulePoint(index)}
- >
- ✕
-
-
- ))}
-
+
+ Add Question
+
+
+ {courseData.finalTest.questions.length > 0 && (
+
+
Test Questions ({courseData.finalTest.questions.length})
+ {courseData.finalTest.questions.map((q, index) => (
+
+ {index + 1}. {q.question}
+ removeQuestionFromTest(index)} className="remove-button">
+ Remove
+
+
+ ))}
)}
+
+
+ );
-
- + Add Module to Course
-
+ case 6:
+ return (
+
+
Completion Badge
+
Design a badge that students will earn upon completing the course
+
+
+ Badge Name
+ handleInputChange('badge', 'name', e.target.value)}
+ placeholder="e.g., Freelance Branding Expert"
+ />
- {courseData.modules.length > 0 && (
-
-
Course Modules ({courseData.modules.length})
- {courseData.modules.map((module, index) => (
-
-
-
- Module {index + 1}
-
{module.title}
- {module.duration && ⏱ {module.duration} }
-
-
handleRemoveModule(index)}
- >
- ✕ Remove
-
-
- {module.description && (
-
{module.description}
- )}
-
- {module.videoUrl && 📹 Video }
- {module.articleContent && 📝 Article }
- {module.pdfUrl && 📄 PDF }
-
- {module.learningPoints.length > 0 && (
-
-
Learning Points:
-
- {module.learningPoints.map((point, idx) => (
- {point}
- ))}
-
-
- )}
-
- ))}
-
- )}
+
+ Badge Description
+
+
+
+ Badge Color
+ handleInputChange('badge', 'color', e.target.value)}
+ />
+
- {courseData.modules.length === 0 && (
-
No modules added yet. Create your first module above!
+
handleInputChange('badge', 'imageUrl', url)}
+ label="Badge Image (optional)"
+ />
+
+ {courseData.badge.name && (
+
+
Badge Preview
+
+ {courseData.badge.imageUrl ? (
+
+ ) : (
+
🏆
+ )}
+
+
{courseData.badge.name}
+
{courseData.badge.description}
+
)}
- )}
+ );
-
-
- Cancel
-
-
- Create Course
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+
navigate(-1)}>
+ Back
+
+
+
{isEditMode ? 'Edit Course' : 'Create New Course'}
+
Fill in the details to create a new course
+
+
+ {steps.map((step, index) => (
+
index + 1 ? 'completed' : ''
+ }`}
+ >
+
{index + 1}
+
{step}
+
+ ))}
+
+
+
+ {renderStepContent()}
+
+
+
+
+ Previous
+
+ {currentStep < steps.length ? (
+
+ Next
+
+ ) : (
+
+ {isEditMode ? 'Update Course' : 'Create Course'}
+
+ )}
diff --git a/frontend/src/pages/Academy/Courses/FinalTest.jsx b/frontend/src/pages/Academy/Courses/FinalTest.jsx
new file mode 100644
index 0000000..1b7e121
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/FinalTest.jsx
@@ -0,0 +1,101 @@
+import React, { useState } from 'react';
+import './CourseLearning.css';
+
+function FinalTest({ courseId, finalTest, onComplete }) {
+ const [answers, setAnswers] = useState({});
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleAnswerChange = (questionIndex, answerIndex) => {
+ setAnswers(prev => ({
+ ...prev,
+ [questionIndex]: answerIndex
+ }));
+ };
+
+ const handleSubmit = async () => {
+ if (Object.keys(answers).length < finalTest.questions.length) {
+ alert('Please answer all questions');
+ return;
+ }
+
+ try {
+ setSubmitting(true);
+
+ const answersArray = finalTest.questions.map((_, index) => answers[index]);
+
+ const res = await fetch(
+ `/api/courses/${courseId}/progress/test/submit`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ answers: answersArray })
+ }
+ );
+
+ const data = await res.json();
+
+ if (data.passed) {
+ onComplete(true, data.badge);
+ } else {
+ alert(`Score: ${data.score}%. You need ${finalTest.passingScore}% to pass. Try again!`);
+ setAnswers({});
+ }
+
+ } catch (err) {
+ console.error('Error submitting test:', err);
+ alert('Failed to submit test');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
📋 {finalTest.title || 'Final Test'}
+
{finalTest.description}
+
+ Passing Score: {finalTest.passingScore}%
+ {finalTest.timeLimit > 0 && (
+ Time Limit: {finalTest.timeLimit} minutes
+ )}
+
+
+
+
+ {finalTest.questions.map((question, index) => (
+
+
Question {index + 1}
+
{question.question}
+
+
+ {question.options.map((option, optionIndex) => (
+
+ handleAnswerChange(index, optionIndex)}
+ />
+ {option}
+
+ ))}
+
+
+ ))}
+
+
+ {submitting ? 'Submitting...' : 'Submit Final Test'}
+
+
+
+ );
+}
+
+export default FinalTest;
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/LearningMaterialsInput.css b/frontend/src/pages/Academy/Courses/LearningMaterialsInput.css
new file mode 100644
index 0000000..55158a4
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/LearningMaterialsInput.css
@@ -0,0 +1,186 @@
+.learning-materials-input {
+ background: #f8f9fa;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+/* Tabs */
+.materials-tabs {
+ display: flex;
+ background: #e8eaed;
+ border-radius: 8px 8px 0 0;
+ overflow: hidden;
+}
+
+.materials-tabs .tab {
+ flex: 1;
+ padding: 1rem;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ font-weight: 500;
+ font-size: 0.95rem;
+ color: #666;
+ transition: all 0.2s;
+ border-bottom: 3px solid transparent;
+}
+
+.materials-tabs .tab:hover {
+ background: rgba(79, 70, 229, 0.05);
+ color: #4F46E5;
+}
+
+.materials-tabs .tab.active {
+ background: white;
+ color: #4F46E5;
+ border-bottom-color: #4F46E5;
+ font-weight: 600;
+}
+
+/* Tab Content */
+.tab-content {
+ background: white;
+ padding: 1.5rem;
+}
+
+.readings-section h4,
+.podcasts-section h4,
+.videos-section h4 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #1a1a1a;
+ margin: 0 0 1.5rem 0;
+}
+
+/* Add Material Button */
+.add-material-button {
+ background: #4F46E5;
+ color: white;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: 8px;
+ font-weight: 500;
+ font-size: 0.95rem;
+ cursor: pointer;
+ margin-top: 1rem;
+ transition: background 0.2s;
+}
+
+.add-material-button:hover {
+ background: #4338CA;
+}
+
+/* Materials List */
+.materials-list {
+ margin-top: 1.5rem;
+}
+
+.material-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 1rem;
+ background: #f8f9fa;
+ border-radius: 8px;
+ margin-bottom: 0.75rem;
+}
+
+.material-item:last-child {
+ margin-bottom: 0;
+}
+
+.material-info {
+ flex: 1;
+}
+
+.material-info strong {
+ display: block;
+ color: #1a1a1a;
+ margin-bottom: 0.25rem;
+ font-size: 0.95rem;
+}
+
+.material-info .author {
+ color: #666;
+ font-size: 0.875rem;
+ font-style: italic;
+}
+
+.material-info .citation {
+ color: #666;
+ font-size: 0.875rem;
+ margin-top: 0.25rem;
+}
+
+.material-link {
+ display: inline-block;
+ margin-top: 0.5rem;
+ color: #4F46E5;
+ font-size: 0.875rem;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.material-link:hover {
+ text-decoration: underline;
+}
+
+/* Form Groups in Material Input */
+.tab-content .form-group {
+ margin-bottom: 1rem;
+}
+
+.tab-content .form-group label {
+ display: block;
+ font-weight: 500;
+ color: #333;
+ margin-bottom: 0.5rem;
+ font-size: 0.9rem;
+}
+
+.tab-content .form-group input,
+.tab-content .form-group textarea {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-size: 0.95rem;
+ transition: border-color 0.2s;
+ font-family: inherit;
+}
+
+.tab-content .form-group input:focus,
+.tab-content .form-group textarea:focus {
+ outline: none;
+ border-color: #4F46E5;
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+/* Empty State */
+.tab-content .empty-state {
+ text-align: center;
+ padding: 2rem;
+ color: #999;
+ font-size: 0.9rem;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .materials-tabs {
+ flex-direction: column;
+ }
+
+ .materials-tabs .tab {
+ border-bottom: 1px solid #ddd;
+ border-right: none;
+ }
+
+ .materials-tabs .tab.active {
+ border-bottom-color: #4F46E5;
+ }
+
+ .tab-content {
+ padding: 1rem;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/LearningMaterialsInput.jsx b/frontend/src/pages/Academy/Courses/LearningMaterialsInput.jsx
new file mode 100644
index 0000000..bc442ab
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/LearningMaterialsInput.jsx
@@ -0,0 +1,283 @@
+import React, { useState } from 'react';
+import './LearningMaterialsInput.css';
+
+function LearningMaterialsInput({ materials, setMaterials }) {
+ const [activeTab, setActiveTab] = useState('readings');
+
+ const [newReading, setNewReading] = useState({
+ title: '',
+ author: '',
+ citation: '',
+ link: ''
+ });
+
+ const [newPodcast, setNewPodcast] = useState({
+ title: '',
+ link: ''
+ });
+
+ const [newVideo, setNewVideo] = useState({
+ title: '',
+ link: ''
+ });
+
+ const addReading = () => {
+ if (!newReading.title || !newReading.citation) {
+ alert('Please fill in at least title and citation');
+ return;
+ }
+
+ setMaterials({
+ ...materials,
+ readings: [...materials.readings, newReading]
+ });
+
+ setNewReading({ title: '', author: '', citation: '', link: '' });
+ };
+
+ const addPodcast = () => {
+ if (!newPodcast.link) {
+ alert('Please enter a podcast link');
+ return;
+ }
+
+ setMaterials({
+ ...materials,
+ podcasts: [...materials.podcasts, newPodcast]
+ });
+
+ setNewPodcast({ title: '', link: '' });
+ };
+
+ const addVideo = () => {
+ if (!newVideo.link) {
+ alert('Please enter a video link');
+ return;
+ }
+
+ setMaterials({
+ ...materials,
+ videos: [...materials.videos, newVideo]
+ });
+
+ setNewVideo({ title: '', link: '' });
+ };
+
+ const removeReading = (index) => {
+ setMaterials({
+ ...materials,
+ readings: materials.readings.filter((_, i) => i !== index)
+ });
+ };
+
+ const removePodcast = (index) => {
+ setMaterials({
+ ...materials,
+ podcasts: materials.podcasts.filter((_, i) => i !== index)
+ });
+ };
+
+ const removeVideo = (index) => {
+ setMaterials({
+ ...materials,
+ videos: materials.videos.filter((_, i) => i !== index)
+ });
+ };
+
+ return (
+
+
+ setActiveTab('readings')}
+ >
+ 📚 Readings ({materials.readings.length})
+
+ setActiveTab('podcasts')}
+ >
+ 🎧 Podcasts ({materials.podcasts.length})
+
+ setActiveTab('videos')}
+ >
+ 🎥 Videos ({materials.videos.length})
+
+
+
+
+ {activeTab === 'readings' && (
+
+
Add Reading Material
+
+
+ Title *
+ setNewReading({ ...newReading, title: e.target.value })}
+ placeholder="e.g., Contagious: Why Things Catch On"
+ />
+
+
+
+ Author
+ setNewReading({ ...newReading, author: e.target.value })}
+ placeholder="e.g., Berger, J."
+ />
+
+
+
+ Citation/Chapter *
+ setNewReading({ ...newReading, citation: e.target.value })}
+ placeholder="e.g., Chapter 1 - Social Currency"
+ />
+
+
+
+ Link (optional)
+ setNewReading({ ...newReading, link: e.target.value })}
+ placeholder="https://..."
+ />
+
+
+
+ + Add Reading
+
+
+ {materials.readings.length > 0 && (
+
+ {materials.readings.map((reading, index) => (
+
+
+
{reading.title}
+ {reading.author &&
by {reading.author} }
+
{reading.citation}
+ {reading.link && (
+
+ View Resource →
+
+ )}
+
+
removeReading(index)} className="remove-button-small">
+ ×
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {activeTab === 'podcasts' && (
+
+
Add Podcast
+
+
+ Title (optional)
+ setNewPodcast({ ...newPodcast, title: e.target.value })}
+ placeholder="e.g., The Q&A Episode"
+ />
+
+
+
+ Podcast Link *
+ setNewPodcast({ ...newPodcast, link: e.target.value })}
+ placeholder="https://podcasts.apple.com/..."
+ />
+
+
+
+ + Add Podcast
+
+
+ {materials.podcasts.length > 0 && (
+
+ {materials.podcasts.map((podcast, index) => (
+
+
+
removePodcast(index)} className="remove-button-small">
+ ×
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {activeTab === 'videos' && (
+
+
Add Video
+
+
+ Title (optional)
+ setNewVideo({ ...newVideo, title: e.target.value })}
+ placeholder="e.g., Introduction to Social Currency"
+ />
+
+
+
+ Video Link *
+ setNewVideo({ ...newVideo, link: e.target.value })}
+ placeholder="https://youtu.be/..."
+ />
+
+
+
+ + Add Video
+
+
+ {materials.videos.length > 0 && (
+
+ {materials.videos.map((video, index) => (
+
+
+
removeVideo(index)} className="remove-button-small">
+ ×
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ );
+}
+
+export default LearningMaterialsInput;
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/ModuleBuilder.css b/frontend/src/pages/Academy/Courses/ModuleBuilder.css
new file mode 100644
index 0000000..4f28594
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/ModuleBuilder.css
@@ -0,0 +1,116 @@
+.module-builder {
+ background: #ffffff;
+ border: 1px solid #e0e0e0;
+ border-radius: 12px;
+ padding: 2rem;
+ margin-bottom: 2rem;
+}
+
+.builder-section {
+ margin-bottom: 2.5rem;
+ padding-bottom: 2rem;
+ border-bottom: 1px solid #f0f0f0;
+}
+
+.builder-section:last-of-type {
+ border-bottom: none;
+ padding-bottom: 0;
+}
+
+.builder-section h3 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #1a1a1a;
+ margin: 0 0 1rem 0;
+}
+
+.builder-section h4 {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: #1a1a1a;
+ margin: 0 0 1rem 0;
+}
+
+.builder-section h5 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #333;
+ margin: 1.5rem 0 1rem 0;
+}
+
+.helper-text {
+ color: #666;
+ font-size: 0.9rem;
+ margin-bottom: 1rem;
+}
+
+/* Module Actions */
+.module-actions {
+ margin-top: 2rem;
+ padding-top: 2rem;
+ border-top: 1px solid #e0e0e0;
+ display: flex;
+ justify-content: flex-end;
+}
+
+.save-module-button {
+ background: #10b981;
+ color: white;
+ padding: 0.875rem 2rem;
+ border: none;
+ border-radius: 8px;
+ font-weight: 600;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.save-module-button:hover {
+ background: #059669;
+}
+
+/* Outcomes List */
+.outcomes-list {
+ background: #f8f9fa;
+ border-radius: 8px;
+ padding: 1rem;
+}
+
+.outcome-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.875rem;
+ background: white;
+ border-radius: 6px;
+ margin-bottom: 0.75rem;
+}
+
+.outcome-item:last-child {
+ margin-bottom: 0;
+}
+
+.outcome-number {
+ font-weight: 600;
+ color: #4F46E5;
+ flex-shrink: 0;
+ min-width: 24px;
+}
+
+.outcome-text {
+ flex: 1;
+ color: #333;
+ line-height: 1.5;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .module-builder {
+ padding: 1.5rem;
+ }
+
+ .builder-section {
+ margin-bottom: 2rem;
+ padding-bottom: 1.5rem;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/ModuleBuilder.jsx b/frontend/src/pages/Academy/Courses/ModuleBuilder.jsx
new file mode 100644
index 0000000..a3318d9
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/ModuleBuilder.jsx
@@ -0,0 +1,295 @@
+import React, { useState } from 'react';
+import './ModuleBuilder.css';
+import './CreateCourse.css';
+import LearningMaterialsInput from './LearningMaterialsInput';
+import AssignmentQuestionBuilder from './AssignmentQuestionBuilder';
+
+function ModuleBuilder({
+ currentModule,
+ setCurrentModule,
+ onSave,
+ newOutcome,
+ setNewOutcome,
+ addLearningOutcome,
+ removeLearningOutcome
+}) {
+
+ /* ===============================
+ ASSIGNMENT STATE
+ =============================== */
+ const [showAssignment, setShowAssignment] = useState(false);
+ const [assignmentQuestions, setAssignmentQuestions] = useState([]);
+
+ /* ===============================
+ MODULE SAVE
+ =============================== */
+ const handleSaveModule = () => {
+ if (!currentModule.title) {
+ alert('Please enter a module title');
+ return;
+ }
+ if (!currentModule.overview) {
+ alert('Please enter a module overview');
+ return;
+ }
+ if (currentModule.learningOutcomes.length === 0) {
+ alert('Please add at least one learning outcome');
+ return;
+ }
+
+ onSave(currentModule);
+
+ // reset assignment builder UI
+ setShowAssignment(false);
+ setAssignmentQuestions([]);
+ };
+
+ /* ===============================
+ ASSIGNMENT LOGIC
+ =============================== */
+
+ const handleAddQuestion = (question) => {
+ const questionWithNumber = {
+ ...question,
+ questionNumber: assignmentQuestions.length + 1
+ };
+ setAssignmentQuestions(prev => [...prev, questionWithNumber]);
+ };
+
+ const removeQuestion = (index) => {
+ setAssignmentQuestions(prev =>
+ prev.filter((_, i) => i !== index)
+ );
+ };
+
+ const handleCreateAssignment = () => {
+ if (assignmentQuestions.length === 0) {
+ alert('Please add at least one question');
+ return;
+ }
+
+ const totalPoints = assignmentQuestions.reduce(
+ (sum, q) => sum + Number(q.points || 0),
+ 0
+ );
+
+ const assignment = {
+ title: `${currentModule.title || 'Module'} Assignment`,
+ instructions: 'Complete all questions below.',
+ questions: assignmentQuestions,
+ totalPoints
+ };
+
+ setCurrentModule(prev => ({
+ ...prev,
+ assignment
+ }));
+
+ setShowAssignment(false);
+ setAssignmentQuestions([]);
+ };
+
+ return (
+
+
+ {/* ===============================
+ MODULE INFO
+ =============================== */}
+
+
Module Information
+
+
+ Module Title *
+
+ setCurrentModule({ ...currentModule, title: e.target.value })
+ }
+ placeholder="e.g., Module 1: Brand Identity"
+ />
+
+
+
+ Module Overview *
+
+
+
+ {/* ===============================
+ LEARNING OUTCOMES
+ =============================== */}
+
+
Learning Outcomes
+
+
+
+ setNewOutcome(e.target.value)}
+ onKeyPress={(e) => e.key === 'Enter' && addLearningOutcome()}
+ />
+
+ Add Outcome
+
+
+
+
+ {currentModule.learningOutcomes.length > 0 ? (
+
+ {currentModule.learningOutcomes.map((outcome, index) => (
+
+ {index + 1}. {outcome}
+ removeLearningOutcome(index)}
+ className="remove-button-small"
+ >
+ ×
+
+
+ ))}
+
+ ) : (
+
+ No learning outcomes added yet
+
+ )}
+
+
+ {/* ===============================
+ LEARNING MATERIALS
+ =============================== */}
+
+
Learning Materials
+
+ setCurrentModule({
+ ...currentModule,
+ learningMaterials: materials
+ })
+ }
+ />
+
+
+ {/* ===============================
+ ASSIGNMENT SECTION
+ =============================== */}
+
+
Assignment (Optional)
+
+ {!currentModule.assignment && !showAssignment && (
+
setShowAssignment(true)}
+ className="secondary-button"
+ >
+ + Create Assignment
+
+ )}
+
+ {showAssignment && (
+
+
+
+
+ {assignmentQuestions.length > 0 && (
+
+
+ Assignment Questions ({assignmentQuestions.length})
+
+
+ {assignmentQuestions.map((q, index) => (
+
+
+ Q{index + 1}: {q.type}
+ {q.points} pts
+ removeQuestion(index)}
+ className="remove-button-small"
+ >
+ Remove
+
+
+
{q.question}
+
+ ))}
+
+
+
+ Save Assignment to Module
+
+
+ {
+ setShowAssignment(false);
+ setAssignmentQuestions([]);
+ }}
+ className="secondary-button"
+ >
+ Cancel
+
+
+
+ )}
+
+ )}
+
+ {currentModule.assignment && !showAssignment && (
+
+
✅ Assignment Added
+
+ {currentModule.assignment.questions.length} questions •{" "}
+ {currentModule.assignment.totalPoints} total points
+
+
+ setCurrentModule({
+ ...currentModule,
+ assignment: null
+ })
+ }
+ className="remove-button"
+ >
+ Remove Assignment
+
+
+ )}
+
+
+ {/* ===============================
+ SAVE MODULE
+ =============================== */}
+
+
+ + Add Module to Course
+
+
+
+
+ );
+}
+
+export default ModuleBuilder;
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/MyCourses.css b/frontend/src/pages/Academy/Courses/MyCourses.css
new file mode 100644
index 0000000..03884f3
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/MyCourses.css
@@ -0,0 +1,56 @@
+.courses-page {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem;
+}
+
+.courses-page h1 {
+ font-size: 2.5rem;
+ margin-bottom: 2rem;
+ color: #333;
+}
+
+.courses-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 2rem;
+ margin-top: 2rem;
+}
+
+/* Empty state styling */
+.courses-page p {
+ text-align: center;
+ color: #666;
+ font-size: 1.1rem;
+ margin-top: 3rem;
+}
+
+/* Loading state */
+.courses-page > p {
+ text-align: center;
+ font-size: 1.2rem;
+ color: #555;
+ margin-top: 3rem;
+}
+
+/* Responsive design */
+@media (max-width: 768px) {
+ .courses-grid {
+ grid-template-columns: 1fr;
+ gap: 1.5rem;
+ }
+
+ .courses-page {
+ padding: 1rem;
+ }
+
+ .courses-page h1 {
+ font-size: 2rem;
+ }
+}
+
+@media (min-width: 769px) and (max-width: 1024px) {
+ .courses-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/MyCourses.jsx b/frontend/src/pages/Academy/Courses/MyCourses.jsx
new file mode 100644
index 0000000..3e771b2
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/MyCourses.jsx
@@ -0,0 +1,61 @@
+import React, { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import CourseCard from "../../../components/Courses/CourseCard";
+import "./MyCourses.css";
+
+const MyCourses = () => {
+ const navigate = useNavigate();
+ const [courses, setCourses] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchMyCourses = async () => {
+ try {
+ const apiBase = process.env.REACT_APP_API_URL || "";
+ const response = await fetch(`${apiBase}/api/users/profile`, {
+ credentials: "include",
+ });
+
+ if (response.status === 401) {
+ navigate("/login?returnTo=/academy/my-courses");
+ return;
+ }
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch profile");
+ }
+
+ const data = await response.json();
+ setCourses(data.enrolledCourses || []);
+ } catch (error) {
+ console.error("Failed to load courses:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchMyCourses();
+ }, [navigate]);
+
+ if (loading) {
+ return Loading your courses...
;
+ }
+
+ return (
+
+
My Courses
+
+ {courses.length === 0 ? (
+
You are not enrolled in any courses yet.
+ ) : (
+
+ {courses.map((course) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default MyCourses;
diff --git a/frontend/src/pages/Academy/Courses/PodcastLesson.jsx b/frontend/src/pages/Academy/Courses/PodcastLesson.jsx
new file mode 100644
index 0000000..ddbc5a2
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/PodcastLesson.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import './CourseLearning.css';
+
+function PodcastLesson({ lesson, onComplete, isCompleted }) {
+ const podcastData = lesson.podcastData;
+
+ return (
+
+
+
🎧 {lesson.title}
+
+
+
+
Lesson Content
+
+
+ Listen to this podcast episode to gain insights on the topic.
+
+
+ {podcastData.link && (
+
+ 🎧 Listen to Podcast →
+
+ )}
+
+
+
+ {!isCompleted ? (
+
+ ✓ Mark as Complete
+
+ ) : (
+
+ ✅ Completed
+
+ )}
+
+
+ );
+}
+
+export default PodcastLesson;
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/QuizLesson.jsx b/frontend/src/pages/Academy/Courses/QuizLesson.jsx
new file mode 100644
index 0000000..ea69f70
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/QuizLesson.jsx
@@ -0,0 +1,152 @@
+import React, { useState } from 'react';
+import './CourseLearning.css';
+
+function QuizLesson({ courseId, lesson, onComplete, progress }) {
+ const [answers, setAnswers] = useState({});
+ const [submitting, setSubmitting] = useState(false);
+ const [result, setResult] = useState(null);
+
+ // Check if already passed
+ const existingResult = progress?.quizResults?.find(
+ qr => qr.lessonId === lesson._id && qr.passed
+ );
+
+ const handleAnswerChange = (questionIndex, answer) => {
+ setAnswers(prev => ({
+ ...prev,
+ [questionIndex]: answer
+ }));
+ };
+
+ const handleSubmit = async () => {
+ // Validate all questions answered
+ if (Object.keys(answers).length < lesson.questions.length) {
+ alert('Please answer all questions');
+ return;
+ }
+
+ try {
+ setSubmitting(true);
+
+ const answersArray = lesson.questions.map((_, index) => answers[index]);
+
+ const res = await fetch(
+ `/api/courses/${courseId}/progress/quiz/${lesson._id}/submit`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ answers: answersArray })
+ }
+ );
+
+ const data = await res.json();
+ setResult(data.result);
+
+ if (data.result.passed) {
+ setTimeout(() => {
+ onComplete();
+ }, 2000);
+ }
+
+ } catch (err) {
+ console.error('Error submitting quiz:', err);
+ alert('Failed to submit quiz');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (existingResult) {
+ return (
+
+
+
❓ {lesson.title}
+
+
+
✅ Quiz Passed!
+
Score: {existingResult.score}%
+
You got {existingResult.correctAnswers} out of {existingResult.totalQuestions} correct
+
+
+ );
+ }
+
+ if (result) {
+ return (
+
+
+
❓ {lesson.title}
+
+
+
{result.passed ? '✅ Passed!' : '❌ Not Passed'}
+
Score: {result.score}%
+
You got {result.correctAnswers} out of {result.totalQuestions} correct
+
Passing score: {lesson.passingScore}%
+
+ {!result.passed && (
+
{
+ setResult(null);
+ setAnswers({});
+ }}>
+ Try Again
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
❓ {lesson.title}
+ Passing: {lesson.passingScore}%
+
+
+
+ {lesson.questions.map((question, index) => (
+
+
Question {index + 1}
+
{question.question}
+
+ {question.questionType === 'multiple-choice' ? (
+
+ {question.options.map((option, optionIndex) => (
+
+ handleAnswerChange(index, optionIndex.toString())}
+ />
+ {option}
+
+ ))}
+
+ ) : (
+
handleAnswerChange(index, e.target.value)}
+ />
+ )}
+
+ ))}
+
+
+ {submitting ? 'Submitting...' : 'Submit Quiz'}
+
+
+
+ );
+}
+
+export default QuizLesson;
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/ReadingLesson.jsx b/frontend/src/pages/Academy/Courses/ReadingLesson.jsx
new file mode 100644
index 0000000..35a1caa
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/ReadingLesson.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import './CourseLearning.css';
+
+function ReadingLesson({ lesson, onComplete, isCompleted }) {
+ const readingData = lesson.readingData;
+
+ return (
+
+
+
📚 {lesson.title}
+
+
+
+
Lesson Content
+
+ {readingData.author && (
+
By {readingData.author}
+ )}
+
+ {readingData.citation && (
+
{readingData.citation}
+ )}
+
+
+ Read the assigned material to deepen your understanding of this topic.
+
+
+ {readingData.link && (
+
+ 📖 Open Reading Material →
+
+ )}
+
+
+
+ {!isCompleted ? (
+
+ ✓ Mark as Complete
+
+ ) : (
+
+ ✅ Completed
+
+ )}
+
+
+ );
+}
+
+export default ReadingLesson;
\ No newline at end of file
diff --git a/frontend/src/pages/Academy/Courses/VideoLesson.jsx b/frontend/src/pages/Academy/Courses/VideoLesson.jsx
new file mode 100644
index 0000000..51100cc
--- /dev/null
+++ b/frontend/src/pages/Academy/Courses/VideoLesson.jsx
@@ -0,0 +1,81 @@
+import React from "react";
+import "./CourseLearning.css";
+
+function VideoLesson({ lesson, onComplete, isCompleted }) {
+ const toEmbedUrl = (url) => {
+ if (!url) {
+ return "";
+ }
+
+ try {
+ const parsedUrl = new URL(url);
+
+ if (parsedUrl.hostname.includes("youtube.com") && parsedUrl.pathname.startsWith("/embed/")) {
+ return url;
+ }
+
+ if (parsedUrl.hostname === "youtu.be") {
+ const id = parsedUrl.pathname.replace("/", "");
+ return id ? `https://www.youtube.com/embed/${id}` : "";
+ }
+
+ if (parsedUrl.hostname.includes("youtube.com")) {
+ const videoId = parsedUrl.searchParams.get("v");
+ return videoId ? `https://www.youtube.com/embed/${videoId}` : "";
+ }
+
+ if (parsedUrl.hostname.includes("vimeo.com") && !parsedUrl.hostname.includes("player.vimeo.com")) {
+ const parts = parsedUrl.pathname.split("/").filter(Boolean);
+ const videoId = [...parts].reverse().find((part) => /^\d+$/.test(part));
+ return videoId ? `https://player.vimeo.com/video/${videoId}` : "";
+ }
+
+ if (parsedUrl.hostname.includes("player.vimeo.com") && parsedUrl.pathname.startsWith("/video/")) {
+ return url;
+ }
+
+ return "";
+ } catch {
+ return "";
+ }
+ };
+
+ const embedUrl = toEmbedUrl(lesson.videoUrl);
+
+ return (
+
+
+
{lesson.title}
+ {lesson.duration && (
+ {lesson.duration}
+ )}
+
+
+
+ {embedUrl ? (
+
+ ) : (
+
Video URL not available
+ )}
+
+
+
+ {!isCompleted ? (
+
+ Mark as Complete
+
+ ) : (
+
Completed
+ )}
+
+
+ );
+}
+
+export default VideoLesson;
diff --git a/frontend/src/pages/Academy/LearningHub/LearningHub.jsx b/frontend/src/pages/Academy/LearningHub/LearningHub.jsx
index 2bb1f32..ff99161 100644
--- a/frontend/src/pages/Academy/LearningHub/LearningHub.jsx
+++ b/frontend/src/pages/Academy/LearningHub/LearningHub.jsx
@@ -68,7 +68,6 @@ function LearningHub() {
const fetchStats = async () => {
try {
// Use configured API URL and new profile endpoint
- // eslint-disable-next-line no-undef
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:5000';
const response = await fetch(`${apiUrl}/api/users/profile`, {
credentials: 'include' // Use cookies!
diff --git a/frontend/src/pages/Academy/Seminars/CreateSeminar.css b/frontend/src/pages/Academy/Seminars/CreateSeminar.css
new file mode 100644
index 0000000..f095447
--- /dev/null
+++ b/frontend/src/pages/Academy/Seminars/CreateSeminar.css
@@ -0,0 +1,273 @@
+.create-seminar-page {
+ min-height: 100vh;
+ background: #f5f5f5;
+ padding: 40px 20px;
+}
+
+.create-seminar-container {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 0 2rem;
+}
+
+.create-seminar-container h1 {
+ font-size: 2.5rem;
+ color: #1a1a1a;
+ margin-bottom: 0.5rem;
+}
+
+.create-seminar-page-subtitle {
+ color: #666;
+ margin-bottom: 3rem;
+}
+
+.create-seminar-page .back-button {
+ background: none;
+ border: none;
+ color: var(--accent-primary-color);
+ font-size: 1rem;
+ cursor: pointer;
+ margin-bottom: 1.5rem;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: color 0.2s;
+}
+
+.create-seminar-page .back-button:hover {
+ color: var(--accent-secondary-color);
+}
+
+.create-seminar-steps-indicator {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 3rem;
+ position: relative;
+}
+
+.create-seminar-steps-indicator::before {
+ content: "";
+ position: absolute;
+ top: 20px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: #e5e5e5;
+ z-index: 0;
+}
+
+.create-seminar-step {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ position: relative;
+ z-index: 1;
+ flex: 1;
+}
+
+.create-seminar-step-number {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: #fff;
+ border: 2px solid #e5e5e5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ color: #999;
+ transition: all 0.3s;
+}
+
+.create-seminar-step.active .create-seminar-step-number {
+ background: var(--accent-primary-color);
+ border-color: var(--accent-primary-color);
+ color: #fff;
+}
+
+.create-seminar-step.completed .create-seminar-step-number {
+ background: #10b981;
+ border-color: #10b981;
+ color: #fff;
+}
+
+.create-seminar-step-label {
+ font-size: 0.875rem;
+ color: #666;
+ text-align: center;
+}
+
+.create-seminar-step.active .create-seminar-step-label {
+ color: var(--accent-primary-color);
+ font-weight: 600;
+}
+
+.create-seminar-form-container {
+ background: #fff;
+ border-radius: 12px;
+ padding: 3rem;
+ margin-bottom: 2rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+}
+
+.create-seminar-form-section h2 {
+ font-size: 1.75rem;
+ color: #1a1a1a;
+ margin-bottom: 0.5rem;
+}
+
+.create-seminar-section-subtitle {
+ color: #666;
+ margin-bottom: 2rem;
+}
+
+.create-seminar-form-group {
+ margin-bottom: 1.5rem;
+}
+
+.create-seminar-form-group label {
+ display: block;
+ font-weight: 600;
+ color: #333;
+ margin-bottom: 0.5rem;
+}
+
+.create-seminar-form-group input,
+.create-seminar-form-group textarea,
+.create-seminar-form-group select {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-family: inherit;
+ transition: border-color 0.2s;
+}
+
+.create-seminar-form-group input:focus,
+.create-seminar-form-group textarea:focus,
+.create-seminar-form-group select:focus {
+ outline: none;
+ border-color: var(--accent-primary-color);
+}
+
+.create-seminar-form-group textarea {
+ resize: vertical;
+}
+
+.create-seminar-form-row {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 1.5rem;
+}
+
+.create-seminar-form-actions {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.create-seminar-primary-button,
+.create-seminar-secondary-button {
+ padding: 1rem 2.5rem;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ border: none;
+}
+
+.create-seminar-primary-button {
+ background: var(--accent-primary-color);
+ color: #fff;
+}
+
+.create-seminar-primary-button:hover:not(:disabled) {
+ background: var(--accent-tertiary-color);
+}
+
+.create-seminar-secondary-button {
+ background: #f3f4f6;
+ color: #374151;
+}
+
+.create-seminar-secondary-button:hover:not(:disabled) {
+ background: #e5e7eb;
+}
+
+.create-seminar-secondary-button:disabled,
+.create-seminar-primary-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+@media (max-width: 768px) {
+ .create-seminar-container {
+ padding: 0 1rem;
+ }
+
+ .create-seminar-page {
+ padding: 20px 15px;
+ }
+
+ .create-seminar-form-container {
+ padding: 2rem 1.5rem;
+ }
+
+ .create-seminar-form-row {
+ grid-template-columns: 1fr;
+ }
+
+ .create-seminar-steps-indicator {
+ flex-wrap: wrap;
+ }
+
+ .create-seminar-step-label {
+ font-size: 0.75rem;
+ }
+
+ .create-seminar-form-actions {
+ flex-direction: column;
+ }
+
+ .create-seminar-primary-button,
+ .create-seminar-secondary-button {
+ width: 100%;
+
+ }
+ .create-seminar-camera {
+ margin-top: 12px;
+}
+
+.create-seminar-camera-panel {
+ margin-top: 10px;
+ border: 1px solid #e7e7e7;
+ border-radius: 10px;
+ padding: 12px;
+ background: #fff;
+}
+
+.create-seminar-camera-preview {
+ width: 100%;
+ max-width: 420px;
+ border-radius: 10px;
+ display: block;
+ background: #000;
+}
+
+.create-seminar-camera-actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 10px;
+}
+
+.create-seminar-camera-error {
+ margin-top: 8px;
+ color: #b00020;
+ font-size: 0.95rem;
+}
+}
+
diff --git a/frontend/src/pages/Academy/Seminars/CreateSeminar.jsx b/frontend/src/pages/Academy/Seminars/CreateSeminar.jsx
index b2a33dc..c3e673c 100644
--- a/frontend/src/pages/Academy/Seminars/CreateSeminar.jsx
+++ b/frontend/src/pages/Academy/Seminars/CreateSeminar.jsx
@@ -1,278 +1,525 @@
-/* global process */
-import React, { useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { FiArrowLeft } from 'react-icons/fi';
+import React, { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { FiArrowLeft } from "react-icons/fi";
+import ImageUpload from "../../../components/ImageUpload";
+import "./CreateSeminar.css";
+
+const getScheduleTimes = ({ date, startTime, endTime }) => {
+ if (!date || !startTime || !endTime) {
+ return { startAtLocal: null, endAtLocal: null, valid: false };
+ }
+
+ const startAtLocal = new Date(`${date}T${startTime}`);
+ const endAtLocal = new Date(`${date}T${endTime}`);
+
+ if (Number.isNaN(startAtLocal.getTime()) || Number.isNaN(endAtLocal.getTime())) {
+ return { startAtLocal: null, endAtLocal: null, valid: false };
+ }
+
+ return {
+ startAtLocal,
+ endAtLocal,
+ valid: endAtLocal > startAtLocal
+ };
+};
+
+const formatDurationLabel = (minutes) => {
+ if (!minutes || !Number.isFinite(minutes)) return "";
+ if (minutes < 60) return `${minutes} minutes`;
+ const hours = Math.floor(minutes / 60);
+ const remainder = minutes % 60;
+ if (!remainder) return `${hours} hour${hours > 1 ? "s" : ""}`;
+ return `${hours} hour${hours > 1 ? "s" : ""} ${remainder} minute${remainder > 1 ? "s" : ""}`;
+};
function CreateSeminar() {
const navigate = useNavigate();
- const [currentStep, setCurrentStep] = useState('basic-info');
- const [seminarData, setSeminarData] = useState({
- title: '',
- description: '',
- duration: '',
- type: 'Live Now',
- thumbnail: '',
-
- speakerName: '',
- speakerBio: '',
- speakerAvatar: '',
-
- date: '',
- time: '',
- joinUrl: ''
- });
+ const [currentStep, setCurrentStep] = useState("basic-info");
+ // Camera state for speaker avatar
+ const [isCameraOpen, setIsCameraOpen] = useState(false);
+ const [cameraError, setCameraError] = useState("");
+ const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
+
+ const videoRef = React.useRef(null);
+ const streamRef = React.useRef(null);
- const handleBack = () => {
- navigate('/academy/create');
+ const stopCamera = () => {
+ const stream = streamRef.current;
+ if (stream) {
+ stream.getTracks().forEach((t) => t.stop());
+ streamRef.current = null;
+ }
+ if (videoRef.current) {
+ videoRef.current.srcObject = null;
+ }
+ setIsCameraOpen(false);
};
- const handleInputChange = (e) => {
- const { name, value } = e.target;
- setSeminarData({
- ...seminarData,
- [name]: value
- });
+ const startCamera = async () => {
+ setCameraError("");
+
+ if (!navigator.mediaDevices?.getUserMedia) {
+ setCameraError("Camera API not supported in this browser.");
+ return;
+ }
+
+ try {
+ // If already open, reset cleanly
+ stopCamera();
+
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: "user" },
+ audio: false
+ });
+
+ streamRef.current = stream;
+ setIsCameraOpen(true); // <-- render the first
+ } catch (err) {
+ console.error("Camera start failed:", err);
+ setCameraError(
+ err?.name === "NotAllowedError"
+ ? "Camera permission denied. Please allow camera access in your browser."
+ : "Unable to access camera. Make sure a camera is available and not in use."
+ );
+ stopCamera();
+ }
};
- const handleCreateSeminar = async () => {
+ React.useEffect(() => {
+ const video = videoRef.current;
+ const stream = streamRef.current;
+
+ if (!isCameraOpen || !video || !stream) return;
+
+ video.srcObject = stream;
+
+ const play = async () => {
+ try {
+ await video.play();
+ } catch (e) {
+ // Some browsers require user interaction; still OK
+ console.warn("Video play() blocked:", e);
+ }
+ };
+
+ // Wait for metadata so videoWidth/videoHeight are available
+ video.onloadedmetadata = play;
+
+ return () => {
+ if (video) video.onloadedmetadata = null;
+ };
+ }, [isCameraOpen]);
+
+ const captureSpeakerAvatar = () => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const w = video.videoWidth;
+ const h = video.videoHeight;
+
+ if (!w || !h) {
+ setCameraError("Camera not ready yet—try again in a moment.");
+ return;
+ }
+
+ const canvas = document.createElement("canvas");
+ canvas.width = w;
+ canvas.height = h;
+
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(video, 0, 0, w, h);
+
+ canvas.toBlob(async (blob) => {
try {
- const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:5000';
- const response = await fetch(`${apiUrl}/api/academy/seminars`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- title: seminarData.title,
- description: seminarData.description,
- duration: seminarData.duration,
- type: seminarData.type,
- thumbnail: seminarData.thumbnail,
-
- speaker: {
- name: seminarData.speakerName,
- bio: seminarData.speakerBio,
- avatar: seminarData.speakerAvatar
- },
-
- schedule: {
- date: seminarData.date,
- time: seminarData.time,
- joinUrl: seminarData.joinUrl
- }
- }),
+ if (!blob) throw new Error("Failed to capture image");
+
+ setIsUploadingAvatar(true);
+ setCameraError("");
+
+ const form = new FormData();
+ form.append("image", blob, "speaker-avatar.png");
+
+ const res = await fetch("/api/upload/image", {
+ method: "POST",
+ body: form,
+ credentials: "include" // safe even if not required
});
- if (response.ok) {
- alert('Seminar created successfully!');
- navigate('/academy');
- } else {
- alert('Failed to create seminar. Please try again.');
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.error || "Upload failed");
}
- } catch (error) {
- console.error('Error creating seminar:', error);
- alert('An error occurred. Please try again.');
+ setFormData((prev) => ({ ...prev, speakerAvatar: data.url }));
+
+ stopCamera();
+ } catch (err) {
+ console.error("Speaker avatar upload failed:", err);
+ setCameraError(err.message || "Failed to upload image");
+ } finally {
+ setIsUploadingAvatar(false);
}
- };
+ }, "image/png", 0.92);
+};
- const handleCancel = () => {
- navigate('/academy/create');
- };
+ // Cleanup if leaving the page
+ React.useEffect(() => {
+ return () => stopCamera();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ const [formData, setFormData] = useState({
+ title: "",
+ description: "",
+ thumbnail: "",
+ speakerName: "",
+ speakerBio: "",
+ speakerAvatar: "",
+ date: "",
+ startTime: "",
+ endTime: "",
+ zoomMeetingId: "",
+ zoomPassword: ""
+ });
+
+ const scheduleTimes = getScheduleTimes(formData);
+ const calculatedDurationMinutes = scheduleTimes.valid
+ ? Math.round((scheduleTimes.endAtLocal.getTime() - scheduleTimes.startAtLocal.getTime()) / 60000)
+ : 0;
+ const calculatedDurationLabel = formatDurationLabel(calculatedDurationMinutes);
const steps = [
- { id: 'basic-info', label: 'Basic Info' },
- { id: 'speaker', label: 'Speaker' },
- { id: 'schedule', label: 'Schedule' },
+ { id: "basic-info", label: "Basic Info" },
+ { id: "speaker", label: "Speaker" },
+ { id: "schedule", label: "Schedule" }
];
- return (
-
-
+ const currentStepIndex = steps.findIndex((step) => step.id === currentStep);
+
+ const handleChange = (event) => {
+ const { name, value } = event.target;
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const isStepValid = (stepId) => {
+ if (stepId === "basic-info") {
+ return formData.title.trim() && formData.description.trim();
+ }
+
+ if (stepId === "speaker") {
+ return formData.speakerName.trim();
+ }
+
+ if (stepId === "schedule") {
+ if (!formData.date || !formData.startTime || !formData.endTime) return false;
+ if (!formData.zoomMeetingId.trim() || !formData.zoomPassword.trim()) return false;
+ return getScheduleTimes(formData).valid;
+ }
+
+ return true;
+ };
+
+ const handleNext = () => {
+ if (!isStepValid(currentStep)) {
+ if (currentStep === "basic-info") {
+ alert("Please fill in title and description before continuing.");
+ } else if (currentStep === "speaker") {
+ alert("Please provide the speaker name before continuing.");
+ }
+ return;
+ }
+
+ if (currentStepIndex < steps.length - 1) {
+ setCurrentStep(steps[currentStepIndex + 1].id);
+ }
+ };
+
+ const handlePrevious = () => {
+ if (currentStepIndex > 0) {
+ setCurrentStep(steps[currentStepIndex - 1].id);
+ }
+ };
+
+ const handleSubmit = async (event) => {
+ if (event) {
+ event.preventDefault();
+ }
-
- Back
+ const { startAtLocal, endAtLocal, valid } = getScheduleTimes(formData);
+ if (!valid || !startAtLocal || !endAtLocal) {
+ alert("Please provide a valid date/time range. End time must be after start time.");
+ return;
+ }
+
+ const durationMinutes = String(Math.round((endAtLocal.getTime() - startAtLocal.getTime()) / 60000));
+
+ const sourceTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
+
+ const payload = {
+ title: formData.title.trim(),
+ description: formData.description.trim(),
+ duration: durationMinutes,
+ thumbnail: formData.thumbnail,
+ speaker: {
+ name: formData.speakerName.trim(),
+ bio: formData.speakerBio.trim(),
+ avatar: formData.speakerAvatar
+ },
+ schedule: {
+ date: formData.date,
+ time: formData.startTime,
+ startAt: startAtLocal.toISOString(),
+ endAt: endAtLocal.toISOString(),
+ sourceTimezone,
+ zoomMeetingId: formData.zoomMeetingId.trim(),
+ zoomPassword: formData.zoomPassword.trim()
+ }
+ };
+
+ try {
+ const response = await fetch("/api/academy/seminars", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload)
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.error || "Failed to create seminar.");
+ }
+
+ alert("Seminar created successfully!");
+ navigate("/academy/seminars");
+ } catch (error) {
+ console.error("Error creating seminar:", error);
+ alert(error.message || "An unexpected error occurred while creating the seminar.");
+ }
+ };
+
+ return (
+
+
+
navigate(-1)}>
+ Back
-
Create New Seminar
-
- Fill in the details to create a new seminar or webinar
-
+
Create New Seminar
+
Fill in the details to schedule a new live seminar
-
- {steps.map((step) => (
-
+ {steps.map((step, index) => (
+ setCurrentStep(step.id)}
+ className={`create-seminar-step ${currentStepIndex === index ? "active" : ""} ${
+ currentStepIndex > index ? "completed" : ""
+ }`}
>
- {step.label}
-
+
{index + 1}
+
{step.label}
+
))}
- {currentStep === 'basic-info' && (
-
-
Seminar Information
-
Basic details about your seminar
-
-
- Seminar Title *
-
-
+
+ {currentStep === "basic-info" && (
+
+
Seminar Information
+
Basic details about your seminar
-
- Description *
-
+
+
+ Description *
+
+
+
+
setFormData((prev) => ({ ...prev, thumbnail: url }))}
+ label="Seminar Thumbnail"
/>
+ )}
+
+ {currentStep === "speaker" && (
+
+
Speaker Information
+
Details about the seminar speaker
-
-
-
Duration
+
+ Speaker Name *
-
-
Seminar Type
-
- Live Now
- Recorded
- Hybrid
-
+
+ Speaker Bio
+
-
-
- Thumbnail URL
-
-
-
- )}
-
- {currentStep === 'speaker' && (
-
-
Speaker Information
-
Details about the seminar speaker
-
-
- Speaker Name *
- setFormData((prev) => ({ ...prev, speakerAvatar: url }))}
+ label="Speaker Avatar"
/>
-
-
- Speaker Bio
-
-
+ {/* Camera controls (right under paste image url / ImageUpload) */}
+
+ {!isCameraOpen ? (
+
+ Use Camera
+
+ ) : (
+
+
-
-
Speaker Avatar URL
-
+
+
+ {isUploadingAvatar ? "Uploading..." : "Capture"}
+
+
+
+ Cancel
+
+
+
+ )}
+
+ {cameraError ?
{cameraError}
: null}
+
-
- )}
+ )}
+
+ {currentStep === "schedule" && (
+
+
Schedule
+
Set the date, time, and Zoom details
- {currentStep === 'schedule' && (
-
-
Schedule
-
When will this seminar take place?
+
+
+ Date *
+
+
-
-
-
Date
+
+ Start Time *
+
+
+
+
+ End Time *
+
+
+
+
+
+ Duration
-
-
Time
+
+ Zoom Meeting ID *
-
-
-
- )}
+ )}
+
- {/* Buttons */}
-
-
- Cancel
-
-
- Create Seminar
+
+
+ Previous
+
+ {currentStepIndex < steps.length - 1 ? (
+
+ Next
+
+ ) : (
+
+ Create Seminar
+
+ )}
diff --git a/frontend/src/pages/Academy/Seminars/SeminarDetails.css b/frontend/src/pages/Academy/Seminars/SeminarDetails.css
new file mode 100644
index 0000000..341593c
--- /dev/null
+++ b/frontend/src/pages/Academy/Seminars/SeminarDetails.css
@@ -0,0 +1,310 @@
+.seminar-details-page {
+ min-height: 100vh;
+ padding: 36px 20px 64px;
+ background-color: #f5f5f5;
+}
+
+.seminar-details-container {
+ max-width: 1180px;
+ margin: 0 auto;
+}
+
+.seminar-details-back-btn {
+ background: none;
+ border: none;
+ color: var(--dark-primary-color);
+ font-size: 16px;
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+ margin-bottom: 30px;
+ padding: 8px 0;
+ transition: color 0.3s ease;
+}
+
+.seminar-details-back-btn:hover {
+ color: var(--dark-secondary-color);
+}
+
+.seminar-details-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
+ gap: 24px;
+}
+
+.seminar-details-main {
+ display: grid;
+ gap: 18px;
+}
+
+.seminar-hero-card,
+.seminar-details-card {
+ background: rgba(255, 255, 255, 0.88);
+ border: 1px solid #dce5f4;
+ border-radius: 18px;
+ backdrop-filter: blur(6px);
+ box-shadow: 0 14px 30px rgba(23, 41, 86, 0.08);
+}
+
+.seminar-hero-media {
+ position: relative;
+ aspect-ratio: 16 / 9;
+ border-radius: 18px 18px 0 0;
+ overflow: hidden;
+}
+
+.seminar-hero-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.seminar-hero-fallback {
+ width: 100%;
+ height: 100%;
+ display: grid;
+ place-items: center;
+ color: #f8fbff;
+ background: linear-gradient(125deg, var(--accent-secondary-color), var(--accent-primary-color) 48%, var(--accent-tertiary-color));
+}
+
+.seminar-status-pill {
+ position: absolute;
+ top: 14px;
+ right: 14px;
+ border-radius: 999px;
+ font-size: 0.78rem;
+ font-weight: 800;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ padding: 6px 12px;
+}
+
+.seminar-status-live-now {
+ background: #df2c3d;
+ color: #fff;
+}
+
+.seminar-status-future {
+ background: #ffe3d9;
+ color: #b84f2f;
+}
+
+.seminar-status-past {
+ background: #e8ebf2;
+ color: #5b667b;
+}
+
+.seminar-hero-content {
+ padding: 20px 22px 24px;
+}
+
+.seminar-details-title {
+ margin: 0 0 8px;
+ color: #132246;
+ font-size: clamp(1.6rem, 2.4vw, 2.25rem);
+ line-height: 1.16;
+}
+
+.seminar-details-description {
+ margin: 0;
+ color: #4c5a77;
+ line-height: 1.65;
+}
+
+.seminar-details-card {
+ padding: 20px 22px;
+}
+
+.seminar-details-card h3 {
+ margin: 0 0 14px;
+ font-size: 1.1rem;
+ color: #152853;
+}
+
+.seminar-details-meta-list {
+ display: grid;
+ gap: 12px;
+}
+
+.seminar-details-meta-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+ color: #243456;
+ border-bottom: 1px dashed #d7e0ee;
+ padding-bottom: 10px;
+}
+
+.seminar-details-meta-item:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
+}
+
+.seminar-details-meta-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 700;
+ color: #253763;
+}
+
+.seminar-details-starts-in {
+ color: var(--accent-primary-color);
+ font-weight: 800;
+}
+
+.seminar-expect-list {
+ display: grid;
+ gap: 8px;
+}
+
+.seminar-expect-list p {
+ margin: 0;
+ color: #304163;
+ line-height: 1.5;
+ padding-left: 14px;
+ position: relative;
+}
+
+.seminar-expect-list p::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0.58em;
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: var(--accent-primary-color);
+}
+
+.seminar-details-sidebar {
+ display: grid;
+ gap: 16px;
+ align-content: start;
+}
+
+.seminar-action-card {
+ background: linear-gradient(150deg, #9f472c 0%, var(--accent-secondary-color) 55%, var(--accent-primary-color) 100%);
+ border: 0;
+ color: #eef5ff;
+}
+
+.seminar-action-card h3 {
+ color: #fff;
+}
+
+.seminar-details-join-btn {
+ width: 100%;
+ border: 0;
+ border-radius: 12px;
+ background: #f0f7ff;
+ color: var(--accent-secondary-color);
+ font-weight: 800;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ cursor: pointer;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.seminar-details-join-btn:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 10px 20px rgba(4, 15, 52, 0.24);
+}
+
+.seminar-details-note {
+ margin: 0;
+ font-size: 0.95rem;
+ line-height: 1.5;
+ color: #e0e9f9;
+}
+
+.seminar-details-link {
+ margin-top: 12px;
+ display: inline-block;
+ color: #dcecff;
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.seminar-details-link:hover {
+ text-decoration: underline;
+}
+
+.seminar-speaker-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.seminar-speaker-avatar,
+.seminar-speaker-fallback {
+ width: 52px;
+ height: 52px;
+ border-radius: 999px;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.seminar-speaker-fallback {
+ display: grid;
+ place-items: center;
+ font-weight: 800;
+ color: #fff;
+ background: linear-gradient(140deg, var(--accent-secondary-color), var(--accent-primary-color));
+}
+
+.seminar-speaker-name {
+ margin: 0;
+ font-weight: 700;
+ color: #1d2f5e;
+}
+
+.seminar-speaker-bio {
+ margin: 12px 0 0;
+ color: #405175;
+ line-height: 1.55;
+}
+
+.seminar-details-feedback {
+ padding: 24px;
+}
+
+@media (max-width: 980px) {
+ .seminar-details-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .seminar-details-sidebar {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (max-width: 680px) {
+ .seminar-details-page {
+ padding: 22px 12px 40px;
+ }
+
+ .seminar-details-back-btn {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .seminar-details-card,
+ .seminar-hero-content {
+ padding: 16px;
+ }
+
+ .seminar-details-meta-item {
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .seminar-details-sidebar {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend/src/pages/Academy/Seminars/SeminarDetails.jsx b/frontend/src/pages/Academy/Seminars/SeminarDetails.jsx
new file mode 100644
index 0000000..2965fe0
--- /dev/null
+++ b/frontend/src/pages/Academy/Seminars/SeminarDetails.jsx
@@ -0,0 +1,210 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { FiArrowLeft, FiCalendar, FiClock, FiExternalLink, FiVideo } from "react-icons/fi";
+import { getSeminarLocalScheduleLabel, getSeminarStatus } from "../../../utils/seminarStatus";
+import "./SeminarDetails.css";
+
+const formatTimeUntilStart = (milliseconds) => {
+ if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
+ return "Starting soon";
+ }
+
+ const totalMinutes = Math.ceil(milliseconds / 60000);
+ const days = Math.floor(totalMinutes / (24 * 60));
+ const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
+ const minutes = totalMinutes % 60;
+
+ const parts = [];
+ if (days > 0) parts.push(`${days}d`);
+ if (hours > 0) parts.push(`${hours}h`);
+ if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
+
+ return parts.join(" ");
+};
+
+const formatDuration = (duration) => {
+ if (!duration) return "Not specified";
+ const numMinutes = Number(duration);
+ if (isNaN(numMinutes)) return duration;
+
+ if (numMinutes < 60) return `${numMinutes} minutes`;
+
+ const hours = Math.floor(numMinutes / 60);
+ const mins = numMinutes % 60;
+
+ if (mins === 0) {
+ return `${hours} hour${hours > 1 ? 's' : ''}`;
+ }
+
+ return `${hours} hour${hours > 1 ? 's' : ''} ${mins} minute${mins > 1 ? 's' : ''}`;
+};
+
+function SeminarDetails() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+
+ const [seminar, setSeminar] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [now, setNow] = useState(Date.now());
+
+ useEffect(() => {
+ const fetchSeminar = async () => {
+ try {
+ const response = await fetch(`/api/academy/seminars/${id}`, { credentials: "include" });
+ if (!response.ok) {
+ throw new Error("Failed to load seminar details");
+ }
+
+ const data = await response.json();
+ setSeminar(data);
+ } catch (fetchError) {
+ setError(fetchError.message || "Could not load seminar");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchSeminar();
+ }, [id]);
+
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ setNow(Date.now());
+ }, 30000);
+
+ return () => window.clearInterval(timer);
+ }, []);
+
+ const status = useMemo(() => getSeminarStatus(seminar, now), [seminar, now]);
+ const canJoinZoom = status === "Live Now" && Boolean(seminar?.schedule?.zoomMeetingId) && Boolean(seminar?.schedule?.zoomPassword);
+
+ const startsIn = useMemo(() => {
+ if (status !== "Future") return "";
+ const startAt = seminar?.schedule?.startAt ? new Date(seminar.schedule.startAt).getTime() : NaN;
+ if (Number.isNaN(startAt)) return "";
+ return formatTimeUntilStart(startAt - now);
+ }, [seminar, status, now]);
+
+ if (loading) {
+ return
Loading seminar details...
;
+ }
+
+ if (error) {
+ return
{error}
;
+ }
+
+ if (!seminar) {
+ return
Seminar not found.
;
+ }
+
+ const speakerName = seminar.speaker?.name || "Unknown Speaker";
+ const speakerInitial = speakerName.trim().charAt(0).toUpperCase() || "S";
+ const isLive = status === "Live Now";
+ const isFuture = status === "Future";
+
+ return (
+
+
+
navigate("/academy/seminars")} className="seminar-details-back-btn">
+ Back to Seminars
+
+
+
+
+
+
+ {seminar.thumbnail ? (
+
+ ) : (
+
+
+
+ )}
+
+ {isLive ? "Live" : status}
+
+
+
+
+
{seminar.title}
+
{seminar.description}
+
+
+
+
+
About This Event
+
+
+ Date & Time
+ {getSeminarLocalScheduleLabel(seminar)}
+
+
+
+ Duration
+ {formatDuration(seminar.duration)}
+
+
+ {isFuture && startsIn ? (
+
+ Starts in
+ {startsIn}
+
+ ) : null}
+
+
+
+
+
+
+
+
{isLive ? "Join Now" : isFuture ? "Upcoming Seminar" : "Seminar Ended"}
+ {canJoinZoom ? (
+
navigate(`/academy/seminars/${id}/join`)}
+ >
+
+ Join Live Session
+
+ ) : (
+
+ Zoom join is only enabled while the seminar is live.
+ {isFuture ? " Please come back closer to start time." : " This seminar has ended."}
+
+ )}
+
+
+
+
Speaker
+
+ {seminar.speaker?.avatar ? (
+
+ ) : (
+
{speakerInitial}
+ )}
+
+
+
{seminar.speaker?.bio || "Speaker bio will be available soon."}
+
+
+
+
What to Expect
+
+
Interactive Q&A with the speaker
+
Live discussion and practical examples
+
Clear takeaways you can apply immediately
+
+
+
+
+
+
+
+ );
+}
+
+export default SeminarDetails;
diff --git a/frontend/src/pages/Academy/Seminars/SeminarZoomPage.jsx b/frontend/src/pages/Academy/Seminars/SeminarZoomPage.jsx
new file mode 100644
index 0000000..bbaf31a
--- /dev/null
+++ b/frontend/src/pages/Academy/Seminars/SeminarZoomPage.jsx
@@ -0,0 +1,74 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import ZoomMeeting from "../../../components/Seminars/ZoomMeeting";
+import { getSeminarStatus } from "../../../utils/seminarStatus";
+
+function SeminarZoomPage() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+
+ const [loading, setLoading] = useState(true);
+ const [seminar, setSeminar] = useState(null);
+ const [userFullName, setUserFullName] = useState("");
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const apiBase = process.env.REACT_APP_API_URL || "";
+
+ const meRes = await fetch(`${apiBase}/api/users/me`, { credentials: "include" });
+ if (!meRes.ok) {
+ navigate("/login");
+ return;
+ }
+
+ const meData = await meRes.json();
+ setUserFullName(`${meData.firstName || ""} ${meData.lastName || ""}`.trim());
+
+ const seminarRes = await fetch(`${apiBase}/api/academy/seminars/${id}`, { credentials: "include" });
+ if (!seminarRes.ok) {
+ throw new Error("Failed to load seminar");
+ }
+
+ const seminarData = await seminarRes.json();
+ setSeminar(seminarData);
+ } catch (fetchError) {
+ setError(fetchError.message || "Could not load meeting details");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [id, navigate]);
+
+ const seminarStatus = useMemo(() => getSeminarStatus(seminar), [seminar]);
+
+ if (loading) {
+ return
Loading meeting details...
;
+ }
+
+ if (error) {
+ return
{error}
;
+ }
+
+ if (!seminar?.schedule?.zoomMeetingId || !seminar?.schedule?.zoomPassword) {
+ return
This seminar is missing Zoom credentials.
;
+ }
+
+ if (seminarStatus !== "Live Now") {
+ return
Zoom join is only available while this seminar is live.
;
+ }
+
+ return (
+
+ );
+}
+
+export default SeminarZoomPage;
diff --git a/frontend/src/pages/Academy/Tutorials/CreateTutorial.css b/frontend/src/pages/Academy/Tutorials/CreateTutorial.css
new file mode 100644
index 0000000..ca1ab4a
--- /dev/null
+++ b/frontend/src/pages/Academy/Tutorials/CreateTutorial.css
@@ -0,0 +1,292 @@
+ .create-tutorial-page {
+ min-height: 100vh;
+ background: #f5f5f5;
+ padding: 40px 20px;
+}
+
+.create-tutorial-container {
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+.create-tutorial-container h1 {
+ font-size: 2.5rem;
+ color: #1a1a1a;
+ margin-bottom: 0.5rem;
+}
+
+.page-subtitle {
+ color: #666;
+ margin-bottom: 3rem;
+}
+
+.back-button {
+ background: none;
+ border: none;
+ color: #4F46E5;
+ font-size: 1rem;
+ cursor: pointer;
+ margin-bottom: 1.5rem;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: color 0.2s;
+}
+
+.back-button:hover {
+ color: #4338ca;
+}
+
+.steps-indicator {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 3rem;
+ position: relative;
+}
+
+.steps-indicator::before {
+ content: '';
+ position: absolute;
+ top: 20px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: #e5e5e5;
+ z-index: 0;
+}
+
+.step {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ position: relative;
+ z-index: 1;
+ flex: 1;
+}
+
+.step-number {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: #fff;
+ border: 2px solid #e5e5e5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ color: #999;
+ transition: all 0.3s;
+}
+
+.step.active .step-number {
+ background: #4F46E5;
+ border-color: #4F46E5;
+ color: white;
+}
+
+.step.completed .step-number {
+ background: #10b981;
+ border-color: #10b981;
+ color: white;
+}
+
+.step-label {
+ font-size: 0.875rem;
+ color: #666;
+ text-align: center;
+}
+
+.step.active .step-label {
+ color: #4F46E5;
+ font-weight: 600;
+}
+
+.form-container {
+ background: white;
+ border-radius: 12px;
+ padding: 3rem;
+ margin-bottom: 2rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+}
+
+.form-section {
+ background: transparent;
+}
+
+.form-section h2 {
+ font-size: 1.75rem;
+ color: #1a1a1a;
+ margin-bottom: 0.5rem;
+}
+
+.section-subtitle {
+ color: #666;
+ margin-bottom: 2rem;
+}
+
+.form-group {
+ margin-bottom: 1.5rem;
+}
+
+.form-group label {
+ display: block;
+ font-weight: 600;
+ color: #333;
+ margin-bottom: 0.5rem;
+}
+
+.form-group input[type="text"],
+.form-group textarea,
+.form-group select {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-family: inherit;
+ transition: border-color 0.2s;
+}
+
+.form-group input[type="number"] {
+ -moz-appearance: textfield;
+ appearance: textfield;
+}
+
+.form-group input[type="number"]::-webkit-outer-spin-button,
+.form-group input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.form-group input:focus,
+.form-group textarea:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #4F46E5;
+}
+
+.form-group textarea {
+ resize: vertical;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.5rem;
+}
+
+.resource-list {
+ margin-bottom: 1rem;
+}
+
+.resource-input {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-family: inherit;
+ margin-bottom: 0.75rem;
+}
+
+.resource-input:last-child {
+ margin-bottom: 0;
+}
+
+.add-button {
+ background: #4F46E5;
+ color: white;
+ border: none;
+ padding: 0.75rem 1.5rem;
+ border-radius: 6px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+ white-space: nowrap;
+}
+
+.add-button:hover {
+ background: #4338ca;
+}
+
+.empty-state {
+ text-align: center;
+ color: #999;
+ font-style: italic;
+ padding: 2rem;
+ background: #f9fafb;
+ border-radius: 8px;
+}
+
+.form-actions {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.primary-button,
+.secondary-button {
+ padding: 1rem 2.5rem;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ border: none;
+}
+
+.primary-button {
+ background: #4F46E5;
+ color: white;
+}
+
+.primary-button:hover {
+ background: #4338ca;
+}
+
+.secondary-button {
+ background: #f3f4f6;
+ color: #374151;
+}
+
+.secondary-button:hover:not(:disabled) {
+ background: #e5e7eb;
+}
+
+.secondary-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+@media (max-width: 768px) {
+ .create-tutorial-page {
+ padding: 20px 15px;
+ }
+
+ .form-container {
+ padding: 2rem 1.5rem;
+ }
+
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+
+ .steps-indicator {
+ flex-wrap: wrap;
+ }
+
+ .step-label {
+ font-size: 0.75rem;
+ }
+
+ .form-actions {
+ flex-direction: column;
+ }
+
+ .primary-button,
+ .secondary-button {
+ width: 100%;
+ }
+}
diff --git a/frontend/src/pages/Academy/Tutorials/CreateTutorial.jsx b/frontend/src/pages/Academy/Tutorials/CreateTutorial.jsx
index 96b8270..5059b00 100644
--- a/frontend/src/pages/Academy/Tutorials/CreateTutorial.jsx
+++ b/frontend/src/pages/Academy/Tutorials/CreateTutorial.jsx
@@ -1,7 +1,8 @@
-/* global process */
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
-import { FiArrowLeft, FiUpload } from "react-icons/fi";
+import "./CreateTutorial.css";
+import { FiArrowLeft } from "react-icons/fi";
+import ImageUpload from "../../../components/ImageUpload";
function CreateTutorial() {
const navigate = useNavigate();
@@ -12,16 +13,28 @@ function CreateTutorial() {
description: "",
duration: "",
category: "",
- thumbnailUrl: "",
+ thumbnail: "",
videoUrl: "",
writtenContent: "",
resources: [],
+ instructorName: "",
});
const handleChange = (e) => {
+ const { name, value } = e.target;
+
+ if (name === "duration") {
+ const digitsOnly = value.replace(/[^0-9]/g, "");
+ setFormData({
+ ...formData,
+ [name]: digitsOnly,
+ });
+ return;
+ }
+
setFormData({
...formData,
- [e.target.name]: e.target.value,
+ [name]: value,
});
};
@@ -38,17 +51,48 @@ function CreateTutorial() {
setFormData({ ...formData, resources: updated });
};
- const handleSubmit = async (e) => {
- e.preventDefault();
+ const parseDurationMinutes = (value) => {
+ if (!value) return "";
+ const normalized = String(value).trim();
+ const numberMatch = normalized.match(/^(\d+)$/);
+ if (!numberMatch) return null;
+ const minutes = Number(numberMatch[1]);
+ if (!Number.isFinite(minutes) || minutes <= 0) return null;
+ return String(minutes);
+ };
+
+ const handleSubmit = async (event) => {
+ if (event) {
+ event.preventDefault();
+ }
+
+ const durationMinutes = parseDurationMinutes(formData.duration);
+ if (durationMinutes === null) {
+ alert("Duration must be a number of minutes (e.g., 15) or left blank for self-paced.");
+ return;
+ }
try {
- console.log("Submitting Tutorial:", formData);
+ const { instructorName, ...rest } = formData;
+ const payload = {
+ ...rest,
+ duration: durationMinutes
+ };
- const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:5000';
- const response = await fetch(`${apiUrl}/api/academy/tutorials`, {
+ const trimmedInstructor = instructorName.trim();
+ if (!trimmedInstructor) {
+ alert("Instructor name is required.");
+ return;
+ }
+
+ payload.instructor = { name: trimmedInstructor };
+
+ console.log("Submitting Tutorial:", payload);
+
+ const response = await fetch(`/api/academy/tutorials`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify(formData),
+ body: JSON.stringify(payload),
});
if (response.ok) {
@@ -68,180 +112,251 @@ function CreateTutorial() {
const steps = [
{ id: 'basic-info', label: 'Basic Info' },
{ id: 'content', label: 'Content' },
- { id: 'resources', label: 'Resources' },
+ { id: 'resources', label: 'Resources' }
];
- return (
-
-
+ const currentStepIndex = steps.findIndex((step) => step.id === currentStep);
+
+ const isStepValid = (stepId) => {
+ if (stepId === "basic-info") {
+ return (
+ formData.title.trim() !== "" &&
+ formData.description.trim() !== "" &&
+ formData.category.trim() !== ""
+ );
+ }
+
+ if (stepId === "content") {
+ return (
+ formData.videoUrl.trim() !== "" ||
+ formData.writtenContent.trim() !== ""
+ );
+ }
+
+ return true;
+ };
+
+ const handleNext = () => {
+ if (!isStepValid(currentStep)) {
+ if (currentStep === "basic-info") {
+ alert("Please fill in all required fields before continuing.");
+ } else if (currentStep === "content") {
+ alert("Please add a video URL or written content before continuing.");
+ }
+ return;
+ }
+
+ if (currentStepIndex < steps.length - 1) {
+ setCurrentStep(steps[currentStepIndex + 1].id);
+ }
+ };
+
+ const handlePrevious = () => {
+ if (currentStepIndex > 0) {
+ setCurrentStep(steps[currentStepIndex - 1].id);
+ }
+ };
- {/* Back Button */}
-
navigate(-1)}>
+ return (
+
+
+
navigate(-1)}>
Back
-
Create New Tutorial
-
- Fill in the details to create a new tutorial
-
+
Create New Tutorial
+
Fill in the details to create a new tutorial
-
- {steps.map((step) => (
-
+ {steps.map((step, index) => (
+ index ? "completed" : ""
}`}
- onClick={() => setCurrentStep(step.id)}
>
- {step.label}
-
+
{index + 1}
+
{step.label}
+
))}
- {currentStep === 'basic-info' && (
-
-
Tutorial Information
-
Basic details about your tutorial
-
-
-
- Tutorial Title *
-
-
-
+
+ {currentStep === "basic-info" && (
+
+
Tutorial Information
+
Basic details about your tutorial
-
-
- Description *
-
-
-
+
+ Tutorial Title *
+
+
+
+
+ Description *
+
+
-
-
-
Duration
+
-
-
Category *
+
+ Instructor *
-
- {/* Thumbnail */}
-
- Thumbnail URL
-
-
-
-
- )}
-
- {currentStep === 'content' && (
-
-
Tutorial Content
-
Video and written content
-
-
- Video URL
- setFormData({ ...formData, thumbnail: url })}
+ label="Tutorial Thumbnail"
/>
+ )}
-
- Written Content
-
-
-
- )}
+ {currentStep === "content" && (
+
+
Tutorial Content
+
Video and written content
- {currentStep === 'resources' && (
-
-
Downloadable Resources
-
Optional supporting materials
-
-
- {formData.resources.length === 0 && (
-
No downloadable resources added yet.
- )}
-
- {formData.resources.map((res, index) => (
+
+ Video URL
- handleResourceChange(index, e.target.value)
- }
+ type="text"
+ name="videoUrl"
+ placeholder="https://youtube.com/watch?v=..."
+ value={formData.videoUrl}
+ onChange={handleChange}
/>
- ))}
+
+
+
+ Written Content
+
+
+ )}
-
- + Add Downloadable Resource
-
-
- )}
+ {currentStep === "resources" && (
+
+
Downloadable Resources
+
Optional supporting materials
- {/* ===================== ACTION BUTTONS ===================== */}
-
-
navigate(-1)}>
- Cancel
-
-
- Create Tutorial
+
+ {formData.resources.length === 0 && (
+
No downloadable resources added yet.
+ )}
+
+ {formData.resources.map((res, index) => (
+
handleResourceChange(index, e.target.value)}
+ />
+ ))}
+
+
+
+ + Add Downloadable Resource
+
+
+ )}
+
+
+
+
+ Previous
+
+ {currentStepIndex < steps.length - 1 ? (
+
+ Next
+
+ ) : (
+
+ Create Tutorial
+
+ )}
diff --git a/frontend/src/pages/Academy/Tutorials/TutorialDetail.jsx b/frontend/src/pages/Academy/Tutorials/TutorialDetail.jsx
index 71a41fb..bca9cf9 100644
--- a/frontend/src/pages/Academy/Tutorials/TutorialDetail.jsx
+++ b/frontend/src/pages/Academy/Tutorials/TutorialDetail.jsx
@@ -1,4 +1,3 @@
-/* global process */
import React, { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { FiArrowLeft, FiClock, FiVideo, FiLink } from "react-icons/fi";
@@ -270,6 +269,7 @@ function TutorialDetail() {
};
run();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [authChecked, isAuthenticated, isBookmarked, isCompleted, location.search]);
const thumbnail = tutorial?.thumbnail || tutorial?.thumbnailUrl;
diff --git a/frontend/src/pages/Academy/components/AcademyPlans.jsx b/frontend/src/pages/Academy/components/AcademyPlans.jsx
index 32a40df..198a389 100644
--- a/frontend/src/pages/Academy/components/AcademyPlans.jsx
+++ b/frontend/src/pages/Academy/components/AcademyPlans.jsx
@@ -1,4 +1,3 @@
-/* global process */
import React, { useEffect, useState } from 'react';
import { FiCheck } from 'react-icons/fi';
import { useNavigate } from 'react-router-dom';
diff --git a/frontend/src/pages/Auth/Login.jsx b/frontend/src/pages/Auth/Login.jsx
index a3fbef8..f6ab059 100644
--- a/frontend/src/pages/Auth/Login.jsx
+++ b/frontend/src/pages/Auth/Login.jsx
@@ -1,4 +1,3 @@
-/* global process */
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
diff --git a/frontend/src/pages/Auth/Profile.jsx b/frontend/src/pages/Auth/Profile.jsx
index 6acc221..acb4011 100644
--- a/frontend/src/pages/Auth/Profile.jsx
+++ b/frontend/src/pages/Auth/Profile.jsx
@@ -1,4 +1,3 @@
-/* global process */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom';
diff --git a/frontend/src/pages/Auth/Signup.jsx b/frontend/src/pages/Auth/Signup.jsx
index 578fb32..c7f1c43 100644
--- a/frontend/src/pages/Auth/Signup.jsx
+++ b/frontend/src/pages/Auth/Signup.jsx
@@ -10,6 +10,7 @@ const Signup = () => {
lastName: '',
username: '',
email: '',
+ accountType: 'student',
password: '',
confirmPassword: ''
});
@@ -44,7 +45,6 @@ const Signup = () => {
}
try {
- // eslint-disable-next-line no-undef
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:5000';
const response = await fetch(`${apiUrl}/api/users/register`, {
method: 'POST',
@@ -56,6 +56,7 @@ const Signup = () => {
lastName: formData.lastName,
username: formData.username,
email: formData.email,
+ accountType: formData.accountType,
password: formData.password
}),
credentials: 'include', // Important for cookies
@@ -149,6 +150,20 @@ const Signup = () => {
/>
+
+ Account Type
+
+ Student
+ Instructor
+
+
+
Password
diff --git a/frontend/src/pages/Instructor/GradingInterface.css b/frontend/src/pages/Instructor/GradingInterface.css
new file mode 100644
index 0000000..9f79a9c
--- /dev/null
+++ b/frontend/src/pages/Instructor/GradingInterface.css
@@ -0,0 +1,945 @@
+/* ================================================
+ GRADING INTERFACE - MAIN LAYOUT
+ ================================================ */
+
+.grading-interface-page {
+ min-height: 100vh;
+ background: #f5f5f5;
+ padding: 2rem;
+}
+
+.grading-container {
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+/* ================================================
+ HEADER
+ ================================================ */
+
+.grading-header {
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.back-button {
+ background: white;
+ border: 2px solid #e5e7eb;
+ padding: 0.75rem 1.5rem;
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 600;
+ color: #6b7280;
+ transition: all 0.2s;
+}
+
+.back-button:hover {
+ border-color: #4F46E5;
+ color: #4F46E5;
+ background: #f9fafb;
+}
+
+.grading-header h1 {
+ margin: 0;
+ font-size: 2rem;
+ color: #1a1a1a;
+}
+
+/* ================================================
+ SUBMISSION INFO CARD
+ ================================================ */
+
+.submission-info-card {
+ background: white;
+ border-radius: 12px;
+ padding: 2rem;
+ margin-bottom: 2rem;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 2rem;
+}
+
+.info-section h3 {
+ margin: 0 0 1rem;
+ color: #1a1a1a;
+ font-size: 1.25rem;
+ border-bottom: 2px solid #e5e7eb;
+ padding-bottom: 0.75rem;
+}
+
+.info-row {
+ margin-top: 1rem;
+}
+
+.student-profile {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.student-avatar-large {
+ width: 60px;
+ height: 60px;
+ border-radius: 50%;
+ overflow: hidden;
+ flex-shrink: 0;
+ background: #e5e7eb;
+}
+
+.student-avatar-large img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.avatar-placeholder-large {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ font-size: 1.75rem;
+ font-weight: 700;
+}
+
+.student-name {
+ display: block;
+ font-size: 1.25rem;
+ color: #1a1a1a;
+ margin-bottom: 0.25rem;
+}
+
+.student-email {
+ display: block;
+ color: #6b7280;
+ font-size: 0.95rem;
+}
+
+.detail-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
+
+.detail-item {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.detail-label {
+ font-weight: 600;
+ color: #6b7280;
+ min-width: 100px;
+}
+
+.detail-value {
+ color: #1a1a1a;
+}
+
+/* ================================================
+ GRADING CONTENT
+ ================================================ */
+
+.grading-content {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+/* ================================================
+ GRADING PART CARD
+ ================================================ */
+
+.grading-part-card {
+ background: white;
+ border-radius: 12px;
+ padding: 2rem;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ border-left: 4px solid #4F46E5;
+}
+
+.part-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ padding-bottom: 1rem;
+ border-bottom: 2px solid #e5e7eb;
+}
+
+.part-header h3 {
+ margin: 0;
+ color: #1a1a1a;
+ font-size: 1.25rem;
+}
+
+.max-points-badge {
+ background: #f0f9ff;
+ color: #0369a1;
+ padding: 0.5rem 1rem;
+ border-radius: 20px;
+ font-weight: 700;
+ font-size: 0.875rem;
+}
+
+.part-instructions {
+ margin-bottom: 1.5rem;
+ padding: 1rem;
+ background: #f9fafb;
+ border-radius: 8px;
+}
+
+.part-instructions strong {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #1a1a1a;
+}
+
+.part-instructions p {
+ margin: 0;
+ color: #4b5563;
+ line-height: 1.6;
+}
+
+/* ================================================
+ STUDENT ANSWER SECTION
+ ================================================ */
+
+.student-answer-section {
+ margin-bottom: 1.5rem;
+}
+
+.student-answer-section strong {
+ display: block;
+ margin-bottom: 0.75rem;
+ color: #1a1a1a;
+ font-size: 1rem;
+}
+
+.student-answer-box {
+ background: #fffbeb;
+ border: 2px solid #fbbf24;
+ padding: 1.25rem;
+ border-radius: 8px;
+ min-height: 100px;
+ color: #1a1a1a;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.no-answer {
+ color: #9ca3af;
+ font-style: italic;
+}
+
+/* ================================================
+ FILE URL SECTION
+ ================================================ */
+
+.file-url-section {
+ margin-bottom: 1.5rem;
+ padding: 1rem;
+ background: #f0f9ff;
+ border-radius: 8px;
+}
+
+.file-url-section strong {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #1a1a1a;
+}
+
+.file-link {
+ color: #4F46E5;
+ text-decoration: none;
+ font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: color 0.2s;
+}
+
+.file-link:hover {
+ color: #4338ca;
+ text-decoration: underline;
+}
+
+/* ================================================
+ GRADING INPUTS
+ ================================================ */
+
+.grading-inputs {
+ display: grid;
+ grid-template-columns: 200px 1fr;
+ gap: 1.5rem;
+ padding: 1.5rem;
+ background: #f0f9ff;
+ border-radius: 8px;
+}
+
+.points-input-section label,
+.comment-input-section label {
+ display: block;
+ font-weight: 600;
+ color: #1a1a1a;
+ margin-bottom: 0.5rem;
+}
+
+.points-input-group {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.points-input {
+ width: 100px;
+ padding: 0.75rem;
+ border: 2px solid #cbd5e1;
+ border-radius: 8px;
+ font-size: 1.25rem;
+ font-weight: 700;
+ text-align: center;
+ transition: border-color 0.2s;
+}
+
+.points-input:focus {
+ outline: none;
+ border-color: #4F46E5;
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+.points-max {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #6b7280;
+}
+
+.comment-textarea {
+ width: 100%;
+ padding: 0.875rem;
+ border: 2px solid #cbd5e1;
+ border-radius: 8px;
+ font-size: 0.95rem;
+ font-family: inherit;
+ resize: vertical;
+ transition: border-color 0.2s;
+}
+
+.comment-textarea:focus {
+ outline: none;
+ border-color: #4F46E5;
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+.comment-textarea::placeholder {
+ color: #9ca3af;
+}
+
+/* ================================================
+ OVERALL FEEDBACK CARD
+ ================================================ */
+
+.overall-feedback-card {
+ background: white;
+ border-radius: 12px;
+ padding: 2rem;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.overall-feedback-card h3 {
+ margin: 0 0 1rem;
+ color: #1a1a1a;
+ font-size: 1.25rem;
+}
+
+.overall-feedback-textarea {
+ width: 100%;
+ padding: 1rem;
+ border: 2px solid #cbd5e1;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-family: inherit;
+ resize: vertical;
+ transition: border-color 0.2s;
+}
+
+.overall-feedback-textarea:focus {
+ outline: none;
+ border-color: #4F46E5;
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+/* ================================================
+ SCORE SUMMARY CARD
+ ================================================ */
+
+.score-summary-card {
+ background: white;
+ border-radius: 12px;
+ padding: 2rem;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ border: 2px solid #e5e7eb;
+}
+
+.score-summary-card h3 {
+ margin: 0 0 1.5rem;
+ color: #1a1a1a;
+ font-size: 1.25rem;
+ border-bottom: 2px solid #e5e7eb;
+ padding-bottom: 0.75rem;
+}
+
+.score-breakdown {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+ padding-bottom: 1.5rem;
+ border-bottom: 2px solid #e5e7eb;
+}
+
+.score-item {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.75rem;
+ background: #f9fafb;
+ border-radius: 6px;
+}
+
+.score-value {
+ font-weight: 700;
+ color: #1a1a1a;
+}
+
+.score-total {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1.5rem;
+ background: #f0f9ff;
+ border-radius: 8px;
+}
+
+.total-row,
+.percentage-row,
+.status-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.total-value {
+ font-size: 1.5rem;
+ color: #1a1a1a;
+}
+
+.percentage-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+}
+
+.percentage-value.passed {
+ color: #10b981;
+}
+
+.percentage-value.failed {
+ color: #ef4444;
+}
+
+.status-badge {
+ padding: 0.625rem 1.25rem;
+ border-radius: 20px;
+ font-weight: 700;
+ font-size: 1rem;
+}
+
+.status-badge.passed {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.status-badge.failed {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+/* ================================================
+ GRADING ACTIONS
+ ================================================ */
+
+.grading-actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: flex-end;
+ padding-top: 1rem;
+}
+
+.cancel-button,
+.submit-grade-button {
+ padding: 1rem 2rem;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.cancel-button {
+ background: white;
+ color: #6b7280;
+ border: 2px solid #e5e7eb;
+}
+
+.cancel-button:hover:not(:disabled) {
+ border-color: #ef4444;
+ color: #ef4444;
+ background: #fef2f2;
+}
+
+.submit-grade-button {
+ background: #4F46E5;
+ color: white;
+}
+
+.submit-grade-button:hover:not(:disabled) {
+ background: #4338ca;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
+}
+
+.cancel-button:disabled,
+.submit-grade-button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+/* ================================================
+ LOADING & ERROR STATES
+ ================================================ */
+
+.loading,
+.error {
+ text-align: center;
+ padding: 4rem 2rem;
+ color: #6b7280;
+ font-size: 1.2rem;
+}
+
+.error {
+ color: #ef4444;
+}
+
+/* ================================================
+ RESPONSIVE
+ ================================================ */
+
+@media (max-width: 1024px) {
+ .submission-info-card {
+ grid-template-columns: 1fr;
+ }
+
+ .grading-inputs {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 768px) {
+ .grading-interface-page {
+ padding: 1rem;
+ }
+
+ .grading-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+
+ .grading-header h1 {
+ font-size: 1.5rem;
+ }
+
+ .back-button {
+ width: 100%;
+ }
+
+ .submission-info-card {
+ padding: 1.5rem;
+ }
+
+ .student-avatar-large {
+ width: 50px;
+ height: 50px;
+ }
+
+ .avatar-placeholder-large {
+ font-size: 1.5rem;
+ }
+
+ .student-name {
+ font-size: 1.125rem;
+ }
+
+ .grading-part-card {
+ padding: 1.5rem;
+ }
+
+ .part-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.75rem;
+ }
+
+ .points-input-group {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .points-input {
+ flex: 1;
+ max-width: 150px;
+ }
+
+ .grading-actions {
+ flex-direction: column;
+ }
+
+ .cancel-button,
+ .submit-grade-button {
+ width: 100%;
+ }
+
+ .score-total {
+ padding: 1rem;
+ }
+
+ .total-value,
+ .percentage-value {
+ font-size: 1.25rem;
+ }
+}
+
+/* Question-Based Assignment Styles */
+.assignment-type-badge {
+ background: #10b981;
+ color: white;
+ padding: 6px 14px;
+ border-radius: 12px;
+ font-size: 13px;
+ font-weight: 500;
+ margin-left: auto;
+}
+
+.grading-question-card {
+ background: white;
+ border: 1px solid #e5e7eb;
+ border-radius: 12px;
+ padding: 24px;
+ margin-bottom: 20px;
+}
+
+.question-header-grade {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-bottom: 15px;
+ border-bottom: 2px solid #f3f4f6;
+}
+
+.question-info {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.question-type-badge-grade {
+ background: #4F46E5;
+ color: white;
+ padding: 4px 12px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.auto-graded-badge {
+ background: #10b981;
+ color: white;
+ padding: 4px 12px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.question-text-grade {
+ margin-bottom: 20px;
+}
+
+.question-text-grade p {
+ margin: 8px 0 0 0;
+ font-size: 16px;
+ color: #374151;
+ line-height: 1.6;
+}
+
+/* Multiple Choice Answer Display */
+.mc-answer-display {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.mc-option {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ background: #f9fafb;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+}
+
+.mc-option.selected {
+ background: #dbeafe;
+ border-color: #3b82f6;
+}
+
+.mc-option.correct {
+ background: #d1fae5;
+ border-color: #10b981;
+}
+
+.mc-option.incorrect {
+ background: #fee2e2;
+ border-color: #ef4444;
+}
+
+.option-letter {
+ font-weight: 600;
+ color: #374151;
+ min-width: 24px;
+}
+
+.correct-badge {
+ margin-left: auto;
+ background: #10b981;
+ color: white;
+ padding: 3px 10px;
+ border-radius: 8px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.incorrect-badge {
+ margin-left: auto;
+ background: #ef4444;
+ color: white;
+ padding: 3px 10px;
+ border-radius: 8px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.selected-badge {
+ background: #3b82f6;
+ color: white;
+ padding: 3px 10px;
+ border-radius: 8px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+/* True/False Answer Display */
+.tf-answer-display {
+ display: flex;
+ gap: 16px;
+ margin-top: 12px;
+}
+
+.tf-option {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 16px;
+ background: #f9fafb;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ font-size: 16px;
+ font-weight: 500;
+}
+
+.tf-option.selected {
+ background: #dbeafe;
+ border-color: #3b82f6;
+}
+
+.tf-option.correct {
+ background: #d1fae5;
+ border-color: #10b981;
+}
+
+.tf-option.incorrect {
+ background: #fee2e2;
+ border-color: #ef4444;
+}
+
+/* Written Answer Display */
+.written-answer-display {
+ margin-top: 12px;
+}
+
+.answer-text {
+ padding: 16px;
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ white-space: pre-wrap;
+ line-height: 1.6;
+ color: #374151;
+}
+
+.word-count-info {
+ margin-top: 8px;
+ font-size: 13px;
+ color: #6b7280;
+}
+
+.rubric-display {
+ margin-top: 16px;
+ padding: 12px;
+ background: #fff9e6;
+ border-left: 4px solid #ffc107;
+ border-radius: 6px;
+}
+
+.rubric-display strong {
+ display: block;
+ margin-bottom: 6px;
+ color: #856404;
+}
+
+.rubric-display p {
+ margin: 0;
+ color: #856404;
+ font-size: 14px;
+}
+
+/* Matching Answer Display */
+.matching-answer-display {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.matching-pair {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 12px 16px;
+ background: #f9fafb;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+}
+
+.matching-pair.correct {
+ background: #d1fae5;
+ border-color: #10b981;
+}
+
+.matching-pair.incorrect {
+ background: #fee2e2;
+ border-color: #ef4444;
+}
+
+.match-left,
+.match-right {
+ font-weight: 500;
+ color: #374151;
+}
+
+.match-arrow {
+ color: #6b7280;
+ font-size: 18px;
+}
+
+.incorrect-info {
+ margin-left: auto;
+ font-size: 13px;
+ color: #dc2626;
+}
+
+/* PDF Answer Display */
+.pdf-answer-display {
+ margin-top: 12px;
+}
+
+.file-link {
+ display: inline-block;
+ padding: 12px 20px;
+ background: #4F46E5;
+ color: white;
+ text-decoration: none;
+ border-radius: 8px;
+ font-weight: 500;
+ transition: background 0.2s;
+}
+
+.file-link:hover {
+ background: #4338ca;
+}
+
+.file-requirements-display {
+ margin-top: 16px;
+ padding: 12px;
+ background: #dbeafe;
+ border-left: 4px solid #3b82f6;
+ border-radius: 6px;
+}
+
+.file-requirements-display strong {
+ display: block;
+ margin-bottom: 6px;
+ color: #1e40af;
+}
+
+.file-requirements-display p {
+ margin: 0;
+ color: #1e40af;
+ font-size: 14px;
+}
+
+/* Auto-grade note */
+.auto-grade-note {
+ margin-left: 8px;
+ font-size: 12px;
+ color: #10b981;
+ font-weight: 500;
+}
+
+.auto-badge-small {
+ margin-left: 6px;
+ font-size: 10px;
+}
+
+/* Score Summary Updates */
+.score-item {
+ display: flex;
+ justify-content: space-between;
+ padding: 10px 0;
+ border-bottom: 1px solid #f3f4f6;
+}
+
+.score-item:last-child {
+ border-bottom: none;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Instructor/GradingInterface.jsx b/frontend/src/pages/Instructor/GradingInterface.jsx
new file mode 100644
index 0000000..f755d2e
--- /dev/null
+++ b/frontend/src/pages/Instructor/GradingInterface.jsx
@@ -0,0 +1,607 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import './GradingInterface.css';
+
+function GradingInterface() {
+ const { submissionId } = useParams();
+ const navigate = useNavigate();
+
+ const [submission, setSubmission] = useState(null);
+ const [courseData, setCourseData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [grading, setGrading] = useState(false);
+
+ // For question-based: { questionNumber: { points: number, comment: string, autoGraded: boolean } }
+ // For part-based: { partNumber: { points: number, maxPoints: number, comment: string } }
+ const [grades, setGrades] = useState({});
+ const [overallFeedback, setOverallFeedback] = useState('');
+
+ const isQuestionBased = submission?.assignmentType === 'question-based';
+
+ useEffect(() => {
+ fetchSubmission();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [submissionId]);
+
+ const fetchSubmission = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(`/api/instructor/submissions/${submissionId}`, {
+ credentials: 'include'
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch submission');
+ }
+
+ const data = await response.json();
+ setSubmission(data);
+
+ // Fetch course to get assignment questions
+ if (data.assignmentType === 'question-based') {
+ const courseRes = await fetch(`/api/academy/courses/${data.course}`, {
+ credentials: 'include'
+ });
+ const course = await courseRes.json();
+ setCourseData(course);
+
+ // Find the module and assignment
+ const module = course.modules.find(m => m._id === data.module);
+ const assignment = module?.assignment;
+
+ if (assignment?.questions) {
+ // Auto-grade multiple-choice and true/false
+ const initialGrades = {};
+
+ assignment.questions.forEach((question) => {
+ const studentAnswer = data.answers?.[question.questionNumber];
+ let points = 0;
+ let autoGraded = false;
+
+ // Auto-grade multiple-choice and true/false
+ if (question.type === 'multiple-choice' || question.type === 'true-false') {
+ if (studentAnswer === question.correctAnswer) {
+ points = question.points;
+ }
+ autoGraded = true;
+ }
+
+ initialGrades[question.questionNumber] = {
+ points,
+ maxPoints: question.points,
+ comment: '',
+ autoGraded
+ };
+ });
+
+ setGrades(initialGrades);
+ }
+ } else {
+ // Old part-based assignment
+ const initialGrades = {};
+ if (data.assignmentData?.parts) {
+ data.assignmentData.parts.forEach((part) => {
+ const criterion = data.assignmentData.gradingCriteria.find(
+ c => c.name.includes(`Part ${part.partNumber}`) || c.name.includes(part.title)
+ );
+
+ initialGrades[part.partNumber] = {
+ points: 0,
+ maxPoints: criterion?.points || 0,
+ comment: ''
+ };
+ });
+ }
+ setGrades(initialGrades);
+ }
+
+ } catch (err) {
+ console.error('Error fetching submission:', err);
+ alert('Failed to load submission');
+ navigate('/instructor/dashboard');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleGradeChange = (itemNumber, field, value) => {
+ setGrades(prev => ({
+ ...prev,
+ [itemNumber]: {
+ ...prev[itemNumber],
+ [field]: field === 'points' ? Number(value) : value
+ }
+ }));
+ };
+
+ const calculateTotalScore = () => {
+ return Object.values(grades).reduce((sum, grade) => sum + (grade.points || 0), 0);
+ };
+
+ const calculateMaxScore = () => {
+ return Object.values(grades).reduce((sum, grade) => sum + (grade.maxPoints || 0), 0);
+ };
+
+ const calculatePercentage = () => {
+ const total = calculateTotalScore();
+ const max = calculateMaxScore();
+ return max > 0 ? Math.round((total / max) * 100) : 0;
+ };
+
+ const handleSubmitGrade = async () => {
+ // Validate all items are graded
+ const allItemsGraded = Object.values(grades).every(grade =>
+ grade.points >= 0 && grade.points <= grade.maxPoints
+ );
+
+ if (!allItemsGraded) {
+ alert('Please grade all items before submitting');
+ return;
+ }
+
+ // Confirm submission
+ const percentage = calculatePercentage();
+ const passed = percentage >= (submission.passingScore || 70);
+ const confirmMessage = `Submit grade: ${calculateTotalScore()}/${calculateMaxScore()} (${percentage}%)\n\nStatus: ${passed ? 'PASSED ✓' : 'NOT PASSED ✗'}\n\nAre you sure?`;
+
+ if (!window.confirm(confirmMessage)) {
+ return;
+ }
+
+ try {
+ setGrading(true);
+
+ const response = await fetch(`/api/instructor/submissions/${submissionId}/grade`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ grades,
+ overallFeedback
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to submit grade');
+ }
+
+ alert('Grade submitted successfully!');
+ navigate('/instructor/dashboard');
+
+ } catch (err) {
+ console.error('Error submitting grade:', err);
+ alert('Failed to submit grade. Please try again.');
+ } finally {
+ setGrading(false);
+ }
+ };
+
+ const handleCancel = () => {
+ if (window.confirm('Are you sure you want to cancel? Your grading progress will be lost.')) {
+ navigate('/instructor/dashboard');
+ }
+ };
+
+ const renderQuestionAnswer = (question, studentAnswer) => {
+ switch (question.type) {
+ case 'multiple-choice':
+ return (
+
+ {question.options.map((option, idx) => (
+
+ {String.fromCharCode(65 + idx)}.
+ {option}
+ {idx === question.correctAnswer && ✓ Correct }
+ {idx === studentAnswer && idx !== question.correctAnswer && ✗ Incorrect }
+ {idx === studentAnswer && Student's Answer }
+
+ ))}
+
+ );
+
+ case 'true-false':
+ const tfOptions = ['True', 'False'];
+ return (
+
+ {tfOptions.map((option, idx) => (
+
+ {option}
+ {idx === question.correctAnswer && ✓ Correct }
+ {idx === studentAnswer && idx !== question.correctAnswer && ✗ Incorrect }
+ {idx === studentAnswer && Student's Answer }
+
+ ))}
+
+ );
+
+ case 'written':
+ return (
+
+
{studentAnswer || No answer provided }
+ {question.wordLimit > 0 && studentAnswer && (
+
+ Word count: {studentAnswer.split(/\s+/).filter(w => w).length} / {question.wordLimit}
+
+ )}
+ {question.rubric && (
+
+
Grading Rubric:
+
{question.rubric}
+
+ )}
+
+ );
+
+ case 'matching':
+ return (
+
+ {question.matchPairs.map((pair, idx) => {
+ const studentMatch = studentAnswer?.[idx];
+ const isCorrect = studentMatch === pair.right;
+
+ return (
+
+ {pair.left}
+ →
+ {studentMatch || No answer }
+ {isCorrect ? (
+ ✓
+ ) : (
+
+ ✗ (Correct: {pair.right})
+
+ )}
+
+ );
+ })}
+
+ );
+
+ case 'pdf-upload':
+ return (
+
+ {studentAnswer ? (
+
+ 📎 View Submitted File →
+
+ ) : (
+
No file submitted
+ )}
+ {question.fileRequirements && (
+
+
Requirements:
+
{question.fileRequirements}
+
+ )}
+
+ );
+
+ default:
+ return
{String(studentAnswer) || No answer }
;
+ }
+ };
+
+ if (loading) {
+ return (
+
+
Loading submission...
+
+ );
+ }
+
+ if (!submission) {
+ return (
+
+ );
+ }
+
+ const totalScore = calculateTotalScore();
+ const maxScore = calculateMaxScore();
+ const percentage = calculatePercentage();
+ const passed = percentage >= (submission.passingScore || 70);
+
+ // Get assignment questions if question-based
+ const module = courseData?.modules?.find(m => m._id === submission.module);
+ const assignment = module?.assignment;
+
+ return (
+
+
+ {/* Header */}
+
+
+ ← Back to Dashboard
+
+
Grade Assignment
+ {isQuestionBased && (
+ Question-Based Assignment
+ )}
+
+
+ {/* Student & Course Info */}
+
+
+
Student Information
+
+
+
+ {submission.student?.avatar ? (
+
+ ) : (
+
+ {submission.studentName?.charAt(0).toUpperCase()}
+
+ )}
+
+
+ {submission.studentName}
+ {submission.studentEmail}
+
+
+
+
+
+
+
Assignment Details
+
+
+ Course:
+ {submission.courseName}
+
+
+ Module:
+ {submission.moduleName}
+
+
+ Assignment:
+ {submission.assignmentTitle}
+
+
+ Submitted:
+
+ {new Date(submission.submittedAt).toLocaleString()}
+
+
+
+
+
+
+ {/* Grading Content */}
+
+ {/* QUESTION-BASED ASSIGNMENT */}
+ {isQuestionBased && assignment?.questions && assignment.questions.map((question, index) => {
+ const questionNumber = question.questionNumber;
+ const studentAnswer = submission.answers?.[questionNumber];
+ const grade = grades[questionNumber] || { points: 0, maxPoints: question.points, comment: '', autoGraded: false };
+
+ return (
+
+
+
+
Question {questionNumber}
+ {question.type}
+ {grade.autoGraded && (
+ 🤖 Auto-Graded
+ )}
+
+
+ Max: {question.points} pts
+
+
+
+
+
Question:
+
{question.question}
+
+
+
+ Student's Answer:
+ {renderQuestionAnswer(question, studentAnswer)}
+
+
+ {/* Grading Inputs */}
+
+
+
Grade (Points):
+
+ handleGradeChange(questionNumber, 'points', e.target.value)}
+ className="points-input"
+ disabled={grade.autoGraded}
+ />
+ / {grade.maxPoints}
+ {grade.autoGraded && (
+ (Auto-graded)
+ )}
+
+
+
+
+ Feedback for this question:
+
+
+
+ );
+ })}
+
+ {/* PART-BASED ASSIGNMENT (OLD) */}
+ {!isQuestionBased && submission.assignmentData?.parts?.map((part, index) => {
+ const partNumber = part.partNumber;
+ const studentAnswer = submission.partAnswers?.get?.(partNumber.toString()) ||
+ submission.partAnswers?.[partNumber];
+ const grade = grades[partNumber] || { points: 0, maxPoints: 0, comment: '' };
+
+ return (
+
+
+
Part {partNumber}: {part.title}
+
+ Max: {grade.maxPoints} pts
+
+
+
+
+
Instructions:
+
{part.instructions}
+
+
+
+
Student's Answer:
+
+ {studentAnswer || No answer provided }
+
+
+
+ {/* File URL if provided */}
+ {submission.fileUrl && index === 0 && (
+
+ )}
+
+ {/* Grading Inputs */}
+
+
+
Grade (Points):
+
+ handleGradeChange(partNumber, 'points', e.target.value)}
+ className="points-input"
+ />
+ / {grade.maxPoints}
+
+
+
+
+ Feedback for this part:
+
+
+
+ );
+ })}
+
+ {/* Overall Feedback */}
+
+
Overall Feedback (Optional)
+
+
+ {/* Score Summary */}
+
+
Score Summary
+
+ {Object.entries(grades).map(([itemNum, grade]) => (
+
+ {isQuestionBased ? `Question ${itemNum}` : `Part ${itemNum}`}
+
+ {grade.points} / {grade.maxPoints} pts
+ {grade.autoGraded && 🤖 }
+
+
+ ))}
+
+
+
+
+ Total Score:
+
+ {totalScore} / {maxScore} pts
+
+
+
+ Percentage:
+
+ {percentage}%
+
+
+
+ Status:
+
+ {passed ? '✓ PASSED' : '✗ NOT PASSED'}
+
+
+
+
+
+ {/* Action Buttons */}
+
+
+ Cancel
+
+
+ {grading ? 'Submitting...' : 'Submit Grade'}
+
+
+
+
+
+ );
+}
+
+export default GradingInterface;
diff --git a/frontend/src/pages/Instructor/InstructorCourseCard.jsx b/frontend/src/pages/Instructor/InstructorCourseCard.jsx
new file mode 100644
index 0000000..0ed2d0d
--- /dev/null
+++ b/frontend/src/pages/Instructor/InstructorCourseCard.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import './InstructorDashboard.css';
+
+function InstructorCourseCard({ course, onView, onEdit }) {
+ return (
+
+ {/* Thumbnail */}
+
+ {course.thumbnail ? (
+
+ ) : (
+
📚
+ )}
+ {course.isLiteVersion && (
+
Lite
+ )}
+
+
+ {/* Content */}
+
+
{course.title}
+
+
+
+ 👥
+ {course.enrolledCount || 0} students
+
+
+ 📚
+ {course.modules?.length || 0} modules
+
+
+
+ {/* Pending Submissions Alert */}
+ {course.pendingSubmissions > 0 && (
+
+ ⚠️
+ {course.pendingSubmissions} submission{course.pendingSubmissions !== 1 ? 's' : ''} pending
+
+ )}
+
+ {/* Price Info */}
+
+ {course.isFree ? (
+ Free
+ ) : (
+ ${course.priceAmount}
+ )}
+ {course.difficulty}
+
+
+
+ {/* Actions */}
+
+ onView(course._id)}
+ >
+ 👁️ View
+
+ onEdit(course._id)}
+ >
+ ✏️ Edit
+
+
+
+ );
+}
+
+export default InstructorCourseCard;
\ No newline at end of file
diff --git a/frontend/src/pages/Instructor/InstructorDashboard.css b/frontend/src/pages/Instructor/InstructorDashboard.css
new file mode 100644
index 0000000..6c9a28f
--- /dev/null
+++ b/frontend/src/pages/Instructor/InstructorDashboard.css
@@ -0,0 +1,1281 @@
+/* ================================================
+ INSTRUCTOR DASHBOARD - MAIN LAYOUT
+ ================================================ */
+
+.instructor-dashboard-page {
+ min-height: 100vh;
+ background: #f5f5f5;
+ padding: 2rem;
+}
+
+.instructor-dashboard-container {
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+/* ================================================
+ HEADER
+ ================================================ */
+
+.dashboard-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 2rem;
+}
+
+.dashboard-header h1 {
+ margin: 0;
+ font-size: 2rem;
+ color: #1a1a1a;
+}
+
+.create-course-button,
+.create-course-button-secondary {
+ background: #4F46E5;
+ color: white;
+ border: none;
+ padding: 0.875rem 1.5rem;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+ font-size: 1rem;
+}
+
+.create-course-button:hover,
+.create-course-button-secondary:hover {
+ background: #4338ca;
+}
+
+.create-course-button-secondary {
+ background: white;
+ color: #4F46E5;
+ border: 2px solid #4F46E5;
+}
+
+.create-course-button-secondary:hover {
+ background: #f0f0f0;
+}
+
+/* ================================================
+ TABS
+ ================================================ */
+
+.dashboard-tabs {
+ display: flex;
+ gap: 0.5rem;
+ border-bottom: 2px solid #e5e7eb;
+ margin-bottom: 2rem;
+ overflow-x: auto;
+}
+
+.dashboard-tabs .tab {
+ background: none;
+ border: none;
+ padding: 1rem 1.5rem;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 1rem;
+ color: #6b7280;
+ border-bottom: 3px solid transparent;
+ transition: all 0.2s;
+ white-space: nowrap;
+ position: relative;
+}
+
+.dashboard-tabs .tab:hover {
+ color: #4F46E5;
+ background: rgba(79, 70, 229, 0.05);
+}
+
+.dashboard-tabs .tab.active {
+ color: #4F46E5;
+ border-bottom-color: #4F46E5;
+}
+
+.dashboard-tabs .tab .badge {
+ background: #ef4444;
+ color: white;
+ font-size: 0.75rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 12px;
+ margin-left: 0.5rem;
+ font-weight: 700;
+}
+
+/* ================================================
+ DASHBOARD CONTENT
+ ================================================ */
+
+.dashboard-content {
+ background: white;
+ border-radius: 12px;
+ padding: 2rem;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+/* ================================================
+ OVERVIEW TAB
+ ================================================ */
+
+.overview-tab {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+}
+
+.recent-activity-section,
+.courses-preview-section {
+ margin-top: 1rem;
+}
+
+.recent-activity-section h2,
+.courses-preview-section h2 {
+ font-size: 1.5rem;
+ color: #1a1a1a;
+ margin: 0 0 1.5rem 0;
+}
+
+.submissions-preview {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.submission-preview-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.25rem;
+ background: #f9fafb;
+ border-radius: 8px;
+ border-left: 4px solid #f59e0b;
+ transition: all 0.2s;
+}
+
+.submission-preview-item:hover {
+ background: #f3f4f6;
+ transform: translateX(4px);
+}
+
+.submission-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.submission-info strong {
+ color: #1a1a1a;
+ font-size: 1rem;
+}
+
+.submission-meta {
+ color: #6b7280;
+ font-size: 0.875rem;
+}
+
+.submission-date {
+ color: #9ca3af;
+ font-size: 0.8rem;
+}
+
+.grade-button-small {
+ background: #4F46E5;
+ color: white;
+ border: none;
+ padding: 0.625rem 1.25rem;
+ border-radius: 6px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.2s;
+ font-size: 0.9rem;
+}
+
+.grade-button-small:hover {
+ background: #4338ca;
+}
+
+.view-all-button {
+ width: 100%;
+ margin-top: 1rem;
+ padding: 0.875rem;
+ background: white;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ color: #4F46E5;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.view-all-button:hover {
+ background: #f9fafb;
+ border-color: #4F46E5;
+}
+
+/* ================================================
+ COURSES GRID
+ ================================================ */
+
+.courses-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 1.5rem;
+ margin-top: 1rem;
+}
+
+/* ================================================
+ COURSES TAB
+ ================================================ */
+
+.courses-tab,
+.submissions-tab,
+.students-tab {
+ min-height: 400px;
+}
+
+.tab-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 2rem;
+}
+
+.tab-header h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ color: #1a1a1a;
+}
+
+/* ================================================
+ STUDENTS TAB
+ ================================================ */
+
+.course-filter {
+ padding: 0.75rem 1rem;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ font-size: 1rem;
+ cursor: pointer;
+ background: white;
+ transition: border-color 0.2s;
+}
+
+.course-filter:focus {
+ outline: none;
+ border-color: #4F46E5;
+}
+
+.all-students-view {
+ text-align: center;
+ padding: 3rem 0;
+}
+
+.helper-text {
+ color: #6b7280;
+ font-size: 1rem;
+ margin-bottom: 2rem;
+}
+
+.courses-list-simple {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ max-width: 600px;
+ margin: 0 auto;
+}
+
+.course-item-simple {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.25rem;
+ background: #f9fafb;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.course-item-simple:hover {
+ border-color: #4F46E5;
+ background: #f0f9ff;
+}
+
+.course-item-simple strong {
+ color: #1a1a1a;
+ font-size: 1rem;
+}
+
+.course-item-simple span {
+ color: #6b7280;
+ font-size: 0.9rem;
+}
+
+/* ================================================
+ EMPTY STATE
+ ================================================ */
+
+.empty-state {
+ text-align: center;
+ padding: 4rem 2rem;
+ color: #6b7280;
+}
+
+.empty-state h3 {
+ margin: 0 0 0.5rem;
+ color: #1a1a1a;
+ font-size: 1.5rem;
+}
+
+.empty-state p {
+ margin: 0 0 1.5rem;
+ font-size: 1rem;
+}
+
+/* ================================================
+ LOADING STATE
+ ================================================ */
+
+.loading {
+ text-align: center;
+ padding: 4rem 2rem;
+ color: #6b7280;
+ font-size: 1.2rem;
+}
+
+/* ================================================
+ RESPONSIVE
+ ================================================ */
+
+@media (max-width: 768px) {
+ .instructor-dashboard-page {
+ padding: 1rem;
+ }
+
+ .dashboard-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+
+ .dashboard-header h1 {
+ font-size: 1.5rem;
+ }
+
+ .create-course-button {
+ width: 100%;
+ }
+
+ .dashboard-tabs {
+ gap: 0;
+ }
+
+ .dashboard-tabs .tab {
+ font-size: 0.875rem;
+ padding: 0.875rem 1rem;
+ }
+
+ .dashboard-content {
+ padding: 1.5rem;
+ }
+
+ .courses-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .submission-preview-item {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+
+ .grade-button-small {
+ width: 100%;
+ }
+
+ .tab-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+
+ .course-filter {
+ width: 100%;
+ }
+}
+
+/* ================================================
+ INSTRUCTOR STATS
+ ================================================ */
+
+.instructor-stats {
+ margin-bottom: 2rem;
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1.5rem;
+}
+
+.stat-card {
+ background: white;
+ border: 2px solid #e5e7eb;
+ border-radius: 12px;
+ padding: 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ transition: all 0.3s ease;
+ position: relative;
+ overflow: hidden;
+}
+
+.stat-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 4px;
+ height: 100%;
+ background: var(--stat-color);
+ transition: width 0.3s ease;
+}
+
+.stat-card:hover {
+ border-color: var(--stat-color);
+ transform: translateY(-4px);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
+}
+
+.stat-card:hover::before {
+ width: 100%;
+ opacity: 0.1;
+}
+
+.stat-card.highlight {
+ border-color: #f59e0b;
+ background: #fffbeb;
+ animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4);
+ }
+ 50% {
+ box-shadow: 0 0 0 8px rgba(245, 158, 11, 0);
+ }
+}
+
+.stat-icon {
+ font-size: 2.5rem;
+ flex-shrink: 0;
+ transition: transform 0.3s ease;
+}
+
+.stat-card:hover .stat-icon {
+ transform: scale(1.1);
+}
+
+.stat-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.stat-value {
+ font-size: 2rem;
+ font-weight: 700;
+ color: #1a1a1a;
+ line-height: 1;
+}
+
+.stat-label {
+ font-size: 0.875rem;
+ color: #6b7280;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.stats-loading {
+ text-align: center;
+ padding: 2rem;
+ color: #6b7280;
+}
+
+/* Responsive Stats */
+@media (max-width: 768px) {
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .stat-card {
+ padding: 1.25rem;
+ }
+
+ .stat-icon {
+ font-size: 2rem;
+ }
+
+ .stat-value {
+ font-size: 1.75rem;
+ }
+}
+
+/* ================================================
+ INSTRUCTOR COURSE CARD
+ ================================================ */
+
+.instructor-course-card {
+ background: white;
+ border: 2px solid #e5e7eb;
+ border-radius: 12px;
+ overflow: hidden;
+ transition: all 0.3s ease;
+ display: flex;
+ flex-direction: column;
+}
+
+.instructor-course-card:hover {
+ border-color: #4F46E5;
+ transform: translateY(-4px);
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
+}
+
+/* Thumbnail */
+.course-card-thumbnail {
+ position: relative;
+ width: 100%;
+ height: 180px;
+ overflow: hidden;
+ background: #f3f4f6;
+}
+
+.course-card-thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.placeholder-thumbnail {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 4rem;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.lite-badge-card {
+ position: absolute;
+ top: 0.75rem;
+ right: 0.75rem;
+ background: #fbbf24;
+ color: #78350f;
+ padding: 0.375rem 0.75rem;
+ border-radius: 6px;
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+/* Content */
+.course-card-content {
+ padding: 1.25rem;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.course-card-title {
+ margin: 0;
+ font-size: 1.125rem;
+ color: #1a1a1a;
+ font-weight: 600;
+ line-height: 1.4;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.course-card-meta {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.meta-item {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
+.meta-icon {
+ font-size: 1rem;
+}
+
+/* Pending Alert */
+.pending-alert {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.625rem 0.875rem;
+ background: #fef3c7;
+ border: 1px solid #fbbf24;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ color: #92400e;
+ font-weight: 600;
+}
+
+.alert-icon {
+ font-size: 1rem;
+}
+
+/* Price Info */
+.course-card-price {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: auto;
+ padding-top: 0.75rem;
+ border-top: 1px solid #e5e7eb;
+}
+
+.price-free {
+ color: #10b981;
+ font-weight: 700;
+ font-size: 1rem;
+}
+
+.price-paid {
+ color: #4F46E5;
+ font-weight: 700;
+ font-size: 1.125rem;
+}
+
+.difficulty-badge {
+ background: #f3f4f6;
+ color: #6b7280;
+ padding: 0.375rem 0.75rem;
+ border-radius: 6px;
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+
+/* Actions */
+.course-card-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ border-top: 2px solid #e5e7eb;
+}
+
+.action-button {
+ padding: 0.875rem;
+ border: none;
+ background: white;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 0.9rem;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+}
+
+.view-button {
+ color: #4F46E5;
+ border-right: 1px solid #e5e7eb;
+}
+
+.view-button:hover {
+ background: #eef2ff;
+}
+
+.edit-button {
+ color: #6b7280;
+}
+
+.edit-button:hover {
+ background: #f3f4f6;
+ color: #1a1a1a;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .course-card-thumbnail {
+ height: 160px;
+ }
+
+ .course-card-title {
+ font-size: 1rem;
+ }
+}
+
+/* ================================================
+ PENDING SUBMISSIONS
+ ================================================ */
+
+.pending-submissions {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.submissions-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.submissions-header h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ color: #1a1a1a;
+}
+
+.refresh-button {
+ background: white;
+ border: 2px solid #e5e7eb;
+ padding: 0.625rem 1.25rem;
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 600;
+ color: #6b7280;
+ transition: all 0.2s;
+}
+
+.refresh-button:hover {
+ border-color: #4F46E5;
+ color: #4F46E5;
+ background: #f9fafb;
+}
+
+/* Search & Filters */
+.submissions-controls {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+}
+
+.search-input {
+ flex: 1;
+ padding: 0.875rem 1.25rem;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ font-size: 1rem;
+ transition: border-color 0.2s;
+}
+
+.search-input:focus {
+ outline: none;
+ border-color: #4F46E5;
+}
+
+.search-input::placeholder {
+ color: #9ca3af;
+}
+
+/* Submissions List */
+.submissions-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.submission-item {
+ background: #f9fafb;
+ border: 2px solid #e5e7eb;
+ border-radius: 12px;
+ padding: 1.5rem;
+ display: grid;
+ grid-template-columns: 200px 1fr auto auto;
+ gap: 1.5rem;
+ align-items: center;
+ transition: all 0.2s;
+}
+
+.submission-item:hover {
+ border-color: #4F46E5;
+ background: white;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+}
+
+/* Student Section */
+.submission-student {
+ display: flex;
+ align-items: center;
+ gap: 0.875rem;
+}
+
+.student-avatar {
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ overflow: hidden;
+ flex-shrink: 0;
+ background: #e5e7eb;
+}
+
+.student-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.avatar-placeholder {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ font-size: 1.25rem;
+ font-weight: 700;
+}
+
+.student-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.student-info strong {
+ color: #1a1a1a;
+ font-size: 1rem;
+}
+
+.student-email {
+ color: #6b7280;
+ font-size: 0.875rem;
+}
+
+/* Assignment Details */
+.submission-details {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.detail-row {
+ display: flex;
+ gap: 0.5rem;
+ font-size: 0.875rem;
+}
+
+.detail-label {
+ color: #6b7280;
+ font-weight: 600;
+ min-width: 90px;
+}
+
+.detail-value {
+ color: #1a1a1a;
+}
+
+/* Status */
+.submission-status {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.status-badge {
+ padding: 0.5rem 1rem;
+ border-radius: 20px;
+ font-size: 0.875rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.status-badge.pending {
+ background: #fef3c7;
+ color: #92400e;
+ border: 2px solid #fbbf24;
+}
+
+.status-badge.graded {
+ background: #d1fae5;
+ color: #065f46;
+ border: 2px solid #10b981;
+}
+
+.max-points {
+ color: #6b7280;
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+/* Action */
+.submission-action {
+ display: flex;
+ align-items: center;
+}
+
+.grade-button {
+ background: #4F46E5;
+ color: white;
+ border: none;
+ padding: 0.875rem 1.5rem;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+ font-size: 0.95rem;
+}
+
+.grade-button:hover {
+ background: #4338ca;
+ transform: translateX(4px);
+}
+
+/* Responsive */
+@media (max-width: 1024px) {
+ .submission-item {
+ grid-template-columns: 1fr;
+ gap: 1rem;
+ }
+
+ .submission-student {
+ padding-bottom: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+ }
+
+ .submission-status {
+ flex-direction: row;
+ justify-content: space-between;
+ padding-top: 1rem;
+ border-top: 1px solid #e5e7eb;
+ }
+
+ .submission-action {
+ justify-content: stretch;
+ }
+
+ .grade-button {
+ width: 100%;
+ }
+}
+
+@media (max-width: 768px) {
+ .submissions-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+
+ .refresh-button {
+ width: 100%;
+ }
+
+ .submission-item {
+ padding: 1rem;
+ }
+
+ .student-avatar {
+ width: 40px;
+ height: 40px;
+ }
+
+ .detail-label {
+ min-width: 80px;
+ }
+}
+
+/* ================================================
+ STUDENTS LIST
+ ================================================ */
+
+.students-list-container {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.students-loading {
+ text-align: center;
+ padding: 3rem;
+ color: #6b7280;
+}
+
+/* Controls */
+.students-controls {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ background: #f9fafb;
+ border-radius: 8px;
+}
+
+.students-count {
+ font-size: 1rem;
+ color: #6b7280;
+}
+
+.students-count strong {
+ color: #1a1a1a;
+ font-size: 1.25rem;
+}
+
+.sort-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.sort-controls label {
+ font-size: 0.875rem;
+ color: #6b7280;
+ font-weight: 600;
+}
+
+.sort-select {
+ padding: 0.5rem 1rem;
+ border: 2px solid #e5e7eb;
+ border-radius: 6px;
+ background: white;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: border-color 0.2s;
+}
+
+.sort-select:focus {
+ outline: none;
+ border-color: #4F46E5;
+}
+
+/* Students Table */
+.students-table {
+ background: white;
+ border: 2px solid #e5e7eb;
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.table-header {
+ display: grid;
+ grid-template-columns: 2fr 2fr 1fr 1fr 1fr;
+ background: #f9fafb;
+ border-bottom: 2px solid #e5e7eb;
+ font-weight: 600;
+ font-size: 0.875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.table-body {
+ display: flex;
+ flex-direction: column;
+}
+
+.table-row {
+ display: grid;
+ grid-template-columns: 2fr 2fr 1fr 1fr 1fr;
+ border-bottom: 1px solid #e5e7eb;
+ transition: background 0.2s;
+}
+
+.table-row:last-child {
+ border-bottom: none;
+}
+
+.table-row:hover {
+ background: #f9fafb;
+}
+
+.table-cell {
+ padding: 1.25rem 1rem;
+ display: flex;
+ align-items: center;
+}
+
+.header-cell {
+ color: #6b7280;
+ padding: 1rem;
+}
+
+.table-cell.center {
+ justify-content: center;
+}
+
+/* Student Cell */
+.student-cell {
+ display: flex;
+ align-items: center;
+ gap: 0.875rem;
+}
+
+.student-avatar-small {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ overflow: hidden;
+ flex-shrink: 0;
+ background: #e5e7eb;
+}
+
+.student-avatar-small img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.avatar-placeholder-small {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ font-size: 1rem;
+ font-weight: 700;
+}
+
+.student-cell strong {
+ color: #1a1a1a;
+ font-size: 0.95rem;
+}
+
+.student-email-cell {
+ color: #6b7280;
+ font-size: 0.875rem;
+}
+
+/* Stats */
+.stat-value-cell {
+ font-weight: 600;
+ color: #1a1a1a;
+ font-size: 1rem;
+}
+
+.stat-value-cell.graded {
+ color: #10b981;
+}
+
+/* Grade Badge */
+.grade-badge-cell {
+ display: flex;
+ justify-content: center;
+}
+
+.grade-value {
+ padding: 0.5rem 1rem;
+ border-radius: 20px;
+ font-weight: 700;
+ font-size: 0.875rem;
+}
+
+.grade-a {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.grade-b {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
+.grade-c {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.grade-low {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.no-grade {
+ color: #9ca3af;
+ font-style: italic;
+ font-size: 0.875rem;
+}
+
+/* Responsive Table */
+@media (max-width: 1024px) {
+ .table-header,
+ .table-row {
+ grid-template-columns: 2fr 2fr 1fr 1fr;
+ }
+
+ .table-header .header-cell:nth-child(4),
+ .table-row .table-cell:nth-child(4) {
+ display: none;
+ }
+}
+
+@media (max-width: 768px) {
+ .students-controls {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+
+ .sort-controls {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .sort-select {
+ flex: 1;
+ }
+
+ /* Stack table on mobile */
+ .table-header {
+ display: none;
+ }
+
+ .table-row {
+ display: flex;
+ flex-direction: column;
+ padding: 1rem;
+ gap: 0.75rem;
+ border-bottom: 2px solid #e5e7eb;
+ }
+
+ .table-cell {
+ padding: 0;
+ justify-content: flex-start;
+ }
+
+ .table-cell.center {
+ justify-content: flex-start;
+ }
+
+ .table-cell::before {
+ content: attr(data-label);
+ font-weight: 600;
+ color: #6b7280;
+ margin-right: 0.5rem;
+ font-size: 0.875rem;
+ min-width: 120px;
+ }
+
+ .student-email-cell::before {
+ content: 'Email: ';
+ }
+
+ .stat-value-cell::before {
+ content: 'Submissions: ';
+ }
+
+ .grade-badge-cell {
+ justify-content: flex-start;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Instructor/InstructorDashboard.jsx b/frontend/src/pages/Instructor/InstructorDashboard.jsx
new file mode 100644
index 0000000..df8bac9
--- /dev/null
+++ b/frontend/src/pages/Instructor/InstructorDashboard.jsx
@@ -0,0 +1,294 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import InstructorStats from './InstructorStats';
+import InstructorCourseCard from './InstructorCourseCard';
+import PendingSubmissions from './PendingSubmissions';
+import StudentsList from './StudentsList';
+import './InstructorDashboard.css';
+
+function InstructorDashboard() {
+ const navigate = useNavigate();
+ const [activeTab, setActiveTab] = useState('overview');
+ const [stats, setStats] = useState(null);
+ const [courses, setCourses] = useState([]);
+ const [pendingSubmissions, setPendingSubmissions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedCourse, setSelectedCourse] = useState(null);
+
+ useEffect(() => {
+ fetchDashboardData();
+ }, []);
+
+ const fetchDashboardData = async () => {
+ try {
+ setLoading(true);
+
+ // Fetch stats
+ const statsRes = await fetch('/api/instructor/dashboard/stats', {
+ credentials: 'include'
+ });
+ const statsData = await statsRes.json();
+ setStats(statsData);
+
+ // Fetch courses
+ const coursesRes = await fetch('/api/instructor/courses', {
+ credentials: 'include'
+ });
+ const coursesData = await coursesRes.json();
+ setCourses(Array.isArray(coursesData) ? coursesData : []);
+
+ // Fetch pending submissions
+ const pendingRes = await fetch('/api/instructor/submissions/pending', {
+ credentials: 'include'
+ });
+ const pendingData = await pendingRes.json();
+
+ // CRITICAL FIX: Ensure pendingSubmissions is always an array
+ setPendingSubmissions(Array.isArray(pendingData) ? pendingData : []);
+
+ } catch (err) {
+ console.error('Error fetching dashboard data:', err);
+ // Set empty arrays on error to prevent crashes
+ setCourses([]);
+ setPendingSubmissions([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleGradeSubmission = (submissionId) => {
+ navigate(`/instructor/grade/${submissionId}`);
+ };
+
+ const handleViewCourse = (courseId) => {
+ navigate(`/academy/courses/${courseId}`);
+ };
+
+ const handleEditCourse = (courseId) => {
+ navigate(`/academy/create/${courseId}`);
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
Instructor Dashboard
+ navigate('/academy/create')}
+ >
+ + Create New Course
+
+
+
+ {/* Tabs */}
+
+ setActiveTab('overview')}
+ >
+ 📊 Overview
+
+ setActiveTab('courses')}
+ >
+ 📚 My Courses
+
+ setActiveTab('submissions')}
+ >
+ 📝 Submissions
+ {pendingSubmissions && pendingSubmissions.length > 0 && (
+ {pendingSubmissions.length}
+ )}
+
+ setActiveTab('students')}
+ >
+ 👥 Students
+
+
+
+ {/* Tab Content */}
+
+ {/* Overview Tab */}
+ {activeTab === 'overview' && (
+
+
+
+ {/* Recent Activity */}
+
+
Recent Submissions
+ {!pendingSubmissions || pendingSubmissions.length === 0 ? (
+
+
No pending submissions. Great job! 🎉
+
+ ) : (
+
+ {(pendingSubmissions || []).slice(0, 5).map((submission) => (
+
+
+ {submission.studentName}
+
+ {submission.courseName} • {submission.assignmentTitle}
+
+
+ {new Date(submission.submittedAt).toLocaleDateString()}
+
+
+
handleGradeSubmission(submission._id)}
+ >
+ Grade
+
+
+ ))}
+ {pendingSubmissions.length > 5 && (
+
setActiveTab('submissions')}
+ >
+ View All {pendingSubmissions.length} Submissions →
+
+ )}
+
+ )}
+
+
+ {/* Courses Preview */}
+
+
My Courses
+
+ {(courses || []).slice(0, 3).map((course) => (
+
+ ))}
+
+ {courses && courses.length > 3 && (
+
setActiveTab('courses')}
+ >
+ View All {courses.length} Courses →
+
+ )}
+
+
+ )}
+
+ {/* My Courses Tab */}
+ {activeTab === 'courses' && (
+
+
+
My Courses ({courses ? courses.length : 0})
+ navigate('/academy/create')}
+ >
+ + New Course
+
+
+
+ {!courses || courses.length === 0 ? (
+
+
No courses yet
+
Create your first course to start teaching!
+
navigate('/academy/create')}
+ >
+ Create Course
+
+
+ ) : (
+
+ {courses.map((course) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Submissions Tab */}
+ {activeTab === 'submissions' && (
+
+ )}
+
+ {/* Students Tab */}
+ {activeTab === 'students' && (
+
+
+
Students Across All Courses
+ setSelectedCourse(e.target.value || null)}
+ >
+ All Courses
+ {(courses || []).map((course) => (
+
+ {course.title}
+
+ ))}
+
+
+
+ {selectedCourse ? (
+
+ ) : (
+
+
+ Select a course above to view enrolled students and their progress.
+
+
+ {(courses || []).map((course) => (
+
setSelectedCourse(course._id)}
+ >
+ {course.title}
+ {course.enrolledCount || 0} students
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+ );
+}
+
+export default InstructorDashboard;
\ No newline at end of file
diff --git a/frontend/src/pages/Instructor/InstructorStats.jsx b/frontend/src/pages/Instructor/InstructorStats.jsx
new file mode 100644
index 0000000..56cce80
--- /dev/null
+++ b/frontend/src/pages/Instructor/InstructorStats.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import './InstructorDashboard.css';
+
+function InstructorStats({ stats }) {
+ if (!stats) {
+ return (
+
+ Loading stats...
+
+ );
+ }
+
+ const statCards = [
+ {
+ icon: '📚',
+ label: 'Total Courses',
+ value: stats.totalCourses,
+ color: '#4F46E5'
+ },
+ {
+ icon: '👥',
+ label: 'Total Students',
+ value: stats.totalStudents,
+ color: '#10b981'
+ },
+ {
+ icon: '📝',
+ label: 'Pending Submissions',
+ value: stats.pendingSubmissions,
+ color: '#f59e0b',
+ highlight: stats.pendingSubmissions > 0
+ },
+ {
+ icon: '✅',
+ label: 'Total Graded',
+ value: stats.totalGraded,
+ color: '#06b6d4'
+ },
+ {
+ icon: '📊',
+ label: 'Average Grade',
+ value: `${stats.averageGrade}%`,
+ color: '#8b5cf6'
+ }
+ ];
+
+ return (
+
+
+ {statCards.map((stat, index) => (
+
+
{stat.icon}
+
+
{stat.value}
+
{stat.label}
+
+
+ ))}
+
+
+ );
+}
+
+export default InstructorStats;
\ No newline at end of file
diff --git a/frontend/src/pages/Instructor/PendingSubmissions.jsx b/frontend/src/pages/Instructor/PendingSubmissions.jsx
new file mode 100644
index 0000000..bf91a87
--- /dev/null
+++ b/frontend/src/pages/Instructor/PendingSubmissions.jsx
@@ -0,0 +1,130 @@
+import React, { useState } from "react";
+import "./InstructorDashboard.css";
+
+function PendingSubmissions({ submissions, onGrade, onRefresh }) {
+ const [searchTerm, setSearchTerm] = useState("");
+
+ const filteredSubmissions = submissions.filter((submission) => {
+ const query = searchTerm.toLowerCase();
+ return (
+ submission.studentName.toLowerCase().includes(query) ||
+ submission.courseName.toLowerCase().includes(query) ||
+ submission.assignmentTitle.toLowerCase().includes(query)
+ );
+ });
+
+ const formatDate = (dateString) => {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffTime = Math.abs(now - date);
+ const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) {
+ return "Today";
+ }
+
+ if (diffDays === 1) {
+ return "Yesterday";
+ }
+
+ if (diffDays < 7) {
+ return `${diffDays} days ago`;
+ }
+
+ return date.toLocaleDateString();
+ };
+
+ return (
+
+
+
Pending Submissions ({submissions.length})
+
+ Refresh
+
+
+
+
+ setSearchTerm(event.target.value)}
+ className="search-input"
+ />
+
+
+ {filteredSubmissions.length === 0 ? (
+
+ {searchTerm ? (
+ <>
+
No results found
+
Try adjusting your search terms
+ >
+ ) : (
+ <>
+
No pending submissions
+
All caught up.
+ >
+ )}
+
+ ) : (
+
+ {filteredSubmissions.map((submission) => (
+
+
+
+ {submission.student?.avatar ? (
+
+ ) : (
+
+ {submission.studentName.charAt(0).toUpperCase()}
+
+ )}
+
+
+ {submission.studentName}
+ {submission.studentEmail}
+
+
+
+
+
+ Course:
+ {submission.courseName}
+
+
+ Module:
+ {submission.moduleName}
+
+
+ Assignment:
+ {submission.assignmentTitle}
+
+
+ Submitted:
+ {formatDate(submission.submittedAt)}
+
+
+
+
+ Pending
+ Max: {submission.maxScore} pts
+
+
+
+ onGrade(submission._id)}
+ >
+ Grade Assignment ->
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default PendingSubmissions;
diff --git a/frontend/src/pages/Instructor/StudentsList.jsx b/frontend/src/pages/Instructor/StudentsList.jsx
new file mode 100644
index 0000000..d881bfb
--- /dev/null
+++ b/frontend/src/pages/Instructor/StudentsList.jsx
@@ -0,0 +1,166 @@
+import React, { useState, useEffect } from 'react';
+import './InstructorDashboard.css';
+
+function StudentsList({ courseId }) {
+ const [students, setStudents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [sortBy, setSortBy] = useState('name'); // name, submissions, grade
+
+ useEffect(() => {
+ if (courseId) {
+ fetchStudents();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [courseId]);
+
+ const fetchStudents = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(`/api/instructor/courses/${courseId}/students`, {
+ credentials: 'include'
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch students');
+ }
+
+ const data = await response.json();
+ setStudents(data);
+ } catch (err) {
+ console.error('Error fetching students:', err);
+ alert('Failed to load students');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Sort students
+ const sortedStudents = [...students].sort((a, b) => {
+ switch (sortBy) {
+ case 'name':
+ const nameA = a.name || a.username || '';
+ const nameB = b.name || b.username || '';
+ return nameA.localeCompare(nameB);
+ case 'submissions':
+ return b.totalSubmissions - a.totalSubmissions;
+ case 'grade':
+ return b.averageGrade - a.averageGrade;
+ default:
+ return 0;
+ }
+ });
+
+ if (loading) {
+ return (
+
+ Loading students...
+
+ );
+ }
+
+ if (students.length === 0) {
+ return (
+
+
No students enrolled
+
Students will appear here once they enroll in this course.
+
+ );
+ }
+
+ return (
+
+ {/* Controls */}
+
+
+ {students.length} student{students.length !== 1 ? 's' : ''} enrolled
+
+
+ Sort by:
+ setSortBy(e.target.value)}
+ className="sort-select"
+ >
+ Name
+ Submissions
+ Average Grade
+
+
+
+
+ {/* Students Table */}
+
+
+
Student
+
Email
+
Submissions
+
Graded
+
Avg Grade
+
+
+
+ {sortedStudents.map((student) => (
+
+ {/* Student Info */}
+
+
+
+ {student.avatar ? (
+
+ ) : (
+
+ {(student.name || student.username || 'U').charAt(0).toUpperCase()}
+
+ )}
+
+
{student.name || student.username || 'Unknown'}
+
+
+
+ {/* Email */}
+
+ {student.email}
+
+
+ {/* Total Submissions */}
+
+
+ {student.totalSubmissions}
+
+
+
+ {/* Graded Submissions */}
+
+
+ {student.gradedSubmissions}
+
+
+
+ {/* Average Grade */}
+
+ {student.gradedSubmissions > 0 ? (
+
+ = 90 ? 'grade-a' :
+ student.averageGrade >= 80 ? 'grade-b' :
+ student.averageGrade >= 70 ? 'grade-c' :
+ 'grade-low'
+ }`}
+ >
+ {student.averageGrade}%
+
+
+ ) : (
+
N/A
+ )}
+
+
+ ))}
+
+
+
+ );
+}
+
+export default StudentsList;
diff --git a/frontend/src/utils/seminarStatus.js b/frontend/src/utils/seminarStatus.js
new file mode 100644
index 0000000..1e6abdf
--- /dev/null
+++ b/frontend/src/utils/seminarStatus.js
@@ -0,0 +1,48 @@
+const LIVE_BUFFER_MS = 30 * 60 * 1000;
+
+export const getSeminarStatus = (seminar, now = Date.now()) => {
+ const startAtRaw = seminar?.schedule?.startAt;
+ const endAtRaw = seminar?.schedule?.endAt;
+
+ const startAt = startAtRaw ? new Date(startAtRaw).getTime() : NaN;
+ const endAt = endAtRaw ? new Date(endAtRaw).getTime() : NaN;
+
+ if (Number.isNaN(startAt) || Number.isNaN(endAt) || endAt <= startAt) {
+ return "Past";
+ }
+
+ if (now < startAt - LIVE_BUFFER_MS) return "Future";
+ if (now > endAt + LIVE_BUFFER_MS) return "Past";
+ return "Live Now";
+};
+
+export const getSeminarLocalScheduleLabel = (seminar) => {
+ const startAtRaw = seminar?.schedule?.startAt;
+ const endAtRaw = seminar?.schedule?.endAt;
+ if (!startAtRaw || !endAtRaw) return seminar?.schedule?.date || "TBD";
+
+ const startAt = new Date(startAtRaw);
+ const endAt = new Date(endAtRaw);
+
+ if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
+ return seminar?.schedule?.date || "TBD";
+ }
+
+ const date = new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric"
+ }).format(startAt);
+
+ const start = new Intl.DateTimeFormat(undefined, {
+ hour: "numeric",
+ minute: "2-digit"
+ }).format(startAt);
+
+ const end = new Intl.DateTimeFormat(undefined, {
+ hour: "numeric",
+ minute: "2-digit"
+ }).format(endAt);
+
+ return `${date}, ${start} - ${end}`;
+};
diff --git a/testing/tests/courses.test.js b/testing/tests/courses.test.js
index b92f4e6..f1ae4b2 100644
--- a/testing/tests/courses.test.js
+++ b/testing/tests/courses.test.js
@@ -1,32 +1,33 @@
+process.env.NODE_ENV = "test";
+process.env.PORT = "0";
+
+const assert = require("assert");
const request = require("supertest");
const { app, server } = require("../../backend/server");
-const assert = require("assert");
-// IMPORTANT — require mongoose from backend folder
+// Require mongoose from the backend install so the server and tests share the same instance.
const mongoose = require("../../backend/node_modules/mongoose");
describe("Courses API Happy Path", function () {
this.timeout(5000);
after(async () => {
- // Close HTTP server
if (server && server.close) {
await server.close();
}
- // Close MongoDB connection
if (mongoose.connection.readyState !== 0) {
await mongoose.connection.close();
}
});
it("should return a list of courses", async function () {
- const res = await request(app).get("/api/academy/courses").expect(200);
+ const response = await request(app).get("/api/academy/courses").expect(200);
- assert(Array.isArray(res.body), "Response should be an array");
+ assert(Array.isArray(response.body), "Response should be an array");
- if (res.body.length > 0) {
- const course = res.body[0];
+ if (response.body.length > 0) {
+ const course = response.body[0];
assert(course._id);
assert(course.title);
assert(course.description);