forked from UKSOURCE/cms.lams
Merge pull request 'feat/truong-20042026-ProgrammeBlog' (#2) from feat/truong-20042026-ProgrammeBlog into develop
Reviewed-on: UKSOURCE/cms.lams#2
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const { ICON_OPTIONS, normalizeIconClass } = require("../utils/iconOptions");
|
||||
const Programme = require("../models/programme");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
/**
|
||||
* Normalize all icon fields in the payload against the ICON_OPTIONS whitelist.
|
||||
*/
|
||||
function sanitizeProgrammeIcons(payload) {
|
||||
if (!payload) return payload;
|
||||
if (Array.isArray(payload.coreCourses)) {
|
||||
payload.coreCourses = payload.coreCourses.map((c) => ({
|
||||
...c,
|
||||
icon: normalizeIconClass(c.icon),
|
||||
}));
|
||||
}
|
||||
if (Array.isArray(payload.outcomes)) {
|
||||
payload.outcomes = payload.outcomes.map((o) => ({
|
||||
...o,
|
||||
icon: normalizeIconClass(o.icon),
|
||||
}));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse a JSON field that may arrive as a string or already-parsed object.
|
||||
*/
|
||||
function parseJsonField(raw) {
|
||||
if (raw == null || raw === "") return null;
|
||||
if (typeof raw === "object") return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ADMIN ROUTES ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /admin/programme
|
||||
* List all programmes.
|
||||
*/
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = await Programme.find().sort({ updatedAt: -1 }).lean();
|
||||
res.render("admin/programme/index", {
|
||||
title: "Programmes Management",
|
||||
data,
|
||||
frontendUrl: process.env.FRONTEND_URL || "",
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("programme.index:", err);
|
||||
req.flash("error_msg", "Error loading programmes");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /admin/programme/edit/:id
|
||||
* Show edit form. Pass id = "new" to create.
|
||||
*/
|
||||
exports.edit = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
let programme;
|
||||
|
||||
if (id === "new") {
|
||||
programme = {
|
||||
_id: null,
|
||||
id: "",
|
||||
level: "",
|
||||
title: "",
|
||||
description: "",
|
||||
duration: "",
|
||||
cost: "",
|
||||
selected: false,
|
||||
link: "",
|
||||
shortName: "",
|
||||
detailBadge: "",
|
||||
detailTitle: "",
|
||||
detailDescription: "",
|
||||
heroImage: "",
|
||||
overview: "",
|
||||
credits: 120,
|
||||
format: "100% Online",
|
||||
nextStartDate: "",
|
||||
monthlyCost: "",
|
||||
perCourseCost: "",
|
||||
coreCourses: [],
|
||||
electives: [],
|
||||
outcomes: [],
|
||||
faqs: [],
|
||||
};
|
||||
} else {
|
||||
programme = await Programme.findById(id).lean();
|
||||
if (!programme) {
|
||||
req.flash("error_msg", "Programme not found");
|
||||
return res.redirect("/admin/programme");
|
||||
}
|
||||
}
|
||||
|
||||
res.render("admin/programme/edit", {
|
||||
title: id === "new" ? "Add New Programme" : `Edit: ${programme.title}`,
|
||||
programme,
|
||||
iconOptions: ICON_OPTIONS,
|
||||
frontendUrl: process.env.FRONTEND_URL || "",
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("programme.edit:", err);
|
||||
req.flash("error_msg", "Error loading programme");
|
||||
res.redirect("/admin/programme");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /admin/programme/update/:id
|
||||
* Save a new or existing programme.
|
||||
*/
|
||||
exports.update = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const body = req.body;
|
||||
|
||||
const coreCourses = parseJsonField(body.coreCourses) || [];
|
||||
const outcomes = parseJsonField(body.outcomes) || [];
|
||||
const faqs = parseJsonField(body.faqs) || [];
|
||||
const electivesRaw = parseJsonField(body.electives) || [];
|
||||
|
||||
// Electives arrive as [{value: "..."}, ...] from the form
|
||||
const electives = electivesRaw.map((e) =>
|
||||
typeof e === "object" ? (e.value || "") : String(e)
|
||||
);
|
||||
|
||||
let payload = {
|
||||
id: (body.id || "").trim(),
|
||||
level: (body.level || "").trim(),
|
||||
title: (body.title || "").trim(),
|
||||
description: (body.description || "").trim(),
|
||||
duration: (body.duration || "").trim(),
|
||||
cost: (body.cost || "").trim(),
|
||||
selected: body.selected === "true" || body.selected === true,
|
||||
link: (body.link || "").trim(),
|
||||
shortName: (body.shortName || "").trim(),
|
||||
detailBadge: (body.detailBadge || "").trim(),
|
||||
detailTitle: (body.detailTitle || "").trim(),
|
||||
detailDescription: (body.detailDescription || "").trim(),
|
||||
overview: (body.overview || "").trim(),
|
||||
credits: parseInt(body.credits, 10) || 0,
|
||||
format: (body.format || "").trim(),
|
||||
nextStartDate: (body.nextStartDate || "").trim(),
|
||||
monthlyCost: (body.monthlyCost || "").trim(),
|
||||
perCourseCost: (body.perCourseCost || "").trim(),
|
||||
coreCourses,
|
||||
electives,
|
||||
outcomes,
|
||||
faqs,
|
||||
};
|
||||
|
||||
// Hero image: file upload takes priority over the text URL field
|
||||
if (req.file) {
|
||||
payload.heroImage = `/uploads/programmes/${req.file.filename}`;
|
||||
} else if (body.heroImageUrl) {
|
||||
payload.heroImage = body.heroImageUrl.trim();
|
||||
}
|
||||
|
||||
// If link is empty, generate from id
|
||||
if (!payload.link && payload.id) {
|
||||
payload.link = `/programmes/${payload.id}`;
|
||||
}
|
||||
|
||||
payload = sanitizeProgrammeIcons(payload);
|
||||
|
||||
if (id === "new") {
|
||||
if (!payload.id) {
|
||||
req.flash("error_msg", "Programme ID / Code is required");
|
||||
return res.redirect("/admin/programme/edit/new");
|
||||
}
|
||||
const doc = await Programme.create(payload);
|
||||
await writeAuditLog({
|
||||
model: "Programme",
|
||||
documentId: doc._id,
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
before: {},
|
||||
after: doc.toObject(),
|
||||
changes: [],
|
||||
req,
|
||||
});
|
||||
req.flash("success_msg", `Programme "${payload.title}" created successfully`);
|
||||
} else {
|
||||
const existing = await Programme.findById(id);
|
||||
if (!existing) {
|
||||
req.flash("error_msg", "Programme not found");
|
||||
return res.redirect("/admin/programme");
|
||||
}
|
||||
const beforeData = JSON.parse(JSON.stringify(existing.toObject()));
|
||||
existing.set(payload);
|
||||
await existing.save();
|
||||
const afterData = JSON.parse(JSON.stringify(existing.toObject()));
|
||||
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Programme",
|
||||
documentId: existing._id,
|
||||
action: AUDIT_ACTIONS.UPDATE,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
req.flash("success_msg", `Programme "${existing.title}" updated successfully`);
|
||||
}
|
||||
|
||||
res.redirect("/admin/programme");
|
||||
} catch (err) {
|
||||
console.error("programme.update:", err);
|
||||
req.flash("error_msg", `Update error: ${err.message}`);
|
||||
res.redirect(id === "new" ? "/admin/programme/edit/new" : `/admin/programme/edit/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /admin/programme/delete/:id
|
||||
* Delete a programme by MongoDB _id.
|
||||
*/
|
||||
exports.delete = async (req, res) => {
|
||||
try {
|
||||
const doc = await Programme.findByIdAndDelete(req.params.id);
|
||||
if (doc) {
|
||||
await writeAuditLog({
|
||||
model: "Programme",
|
||||
documentId: req.params.id,
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
before: doc.toObject(),
|
||||
after: {},
|
||||
changes: [],
|
||||
req,
|
||||
});
|
||||
}
|
||||
req.flash("success_msg", "Programme deleted successfully");
|
||||
res.redirect("/admin/programme");
|
||||
} catch (err) {
|
||||
console.error("programme.delete:", err);
|
||||
req.flash("error_msg", "Error deleting programme");
|
||||
res.redirect("/admin/programme");
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUBLIC API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/programmes
|
||||
* Return all programmes as JSON.
|
||||
*/
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const data = await Programme.find().sort({ updatedAt: -1 }).lean();
|
||||
const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
res.json(addBaseUrlToImages(data, baseUrl));
|
||||
} catch (err) {
|
||||
console.error("programme.api:", err);
|
||||
res.status(500).json({ error: "Error loading programmes" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/programmes/:id
|
||||
* Return a single programme matched by the slug `id` field.
|
||||
*/
|
||||
exports.apiDetail = async (req, res) => {
|
||||
try {
|
||||
const data = await Programme.findOne({ id: req.params.id }).lean();
|
||||
if (!data) return res.status(404).json({ error: "Programme not found" });
|
||||
const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
res.json(addBaseUrlToImages(data, baseUrl));
|
||||
} catch (err) {
|
||||
console.error("programme.apiDetail:", err);
|
||||
res.status(500).json({ error: "Error loading programme" });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
[
|
||||
{
|
||||
"id": "bs-cs",
|
||||
"level": "Bachelor's",
|
||||
"title": "B.S. in Computer Science",
|
||||
"description": "Master software engineering, algorithms, and system design in this comprehensive asynchronous program.",
|
||||
"duration": "4 Years (Flexible)",
|
||||
"cost": "$299 / month",
|
||||
"selected": false,
|
||||
"link": "/programmes/bs-cs",
|
||||
"shortName": "B.S.",
|
||||
"detailBadge": "Bachelor of Science",
|
||||
"detailTitle": "Computer Science",
|
||||
"detailDescription": "Master software engineering, algorithms, and system design in this comprehensive asynchronous program designed for the modern tech landscape.",
|
||||
"heroImage": "/uploads/programmes/bs-cs-hero.png",
|
||||
"overview": "The B.S. in Computer Science program prepares you for a successful career in software development, data engineering, and systems architecture. Through our flexible, subscription-based model, you can learn at your own pace while mastering the core principles of computer science.",
|
||||
"credits": 120,
|
||||
"format": "100% Online",
|
||||
"coreCourses": [
|
||||
{ "id": "CS101", "title": "CS101: Introduction to Programming", "description": "Fundamentals of Python and computational thinking.", "icon": "fa-solid fa-code" },
|
||||
{ "id": "CS201", "title": "CS201: Data Structures & Algorithms", "description": "Analysis and implementation of core data structures.", "icon": "fa-solid fa-database" },
|
||||
{ "id": "CS301", "title": "CS301: Computer Networks", "description": "Protocols, routing, and network architecture.", "icon": "fa-solid fa-network-wired" }
|
||||
],
|
||||
"electives": ["Artificial Intelligence", "Cybersecurity Fundamentals", "Cloud Computing", "Mobile App Development"],
|
||||
"outcomes": [
|
||||
{ "title": "Software Engineering", "description": "Design, develop, and test scalable software systems using modern methodologies.", "icon": "fa-solid fa-laptop-code" },
|
||||
{ "title": "Problem Solving", "description": "Apply algorithmic thinking to solve complex computational problems efficiently.", "icon": "fa-solid fa-brain" }
|
||||
],
|
||||
"faqs": [
|
||||
{ "question": "Is this program fully asynchronous?", "answer": "Yes, all coursework is designed to be completed entirely online and on your own schedule. There are no mandatory live login times." },
|
||||
{ "question": "Can I transfer credits from another institution?", "answer": "Absolutely. We accept up to 90 transfer credits for bachelor's degree programs from accredited institutions." }
|
||||
],
|
||||
"nextStartDate": "September 1st",
|
||||
"monthlyCost": "$299",
|
||||
"perCourseCost": "$450"
|
||||
},
|
||||
{
|
||||
"id": "mba-db",
|
||||
"level": "Master's",
|
||||
"title": "MBA in Digital Business",
|
||||
"description": "Lead the digital transformation with advanced business strategies and technology management skills.",
|
||||
"duration": "18 Months",
|
||||
"cost": "$450 / month",
|
||||
"selected": true,
|
||||
"link": "/programmes/mba-db",
|
||||
"shortName": "MBA",
|
||||
"detailBadge": "Master of Business Administration",
|
||||
"detailTitle": "Digital Business",
|
||||
"detailDescription": "Lead the digital transformation with advanced business strategies and technology management skills in our flexible online program.",
|
||||
"heroImage": "/uploads/programmes/mba-db-hero.png",
|
||||
"overview": "The MBA in Digital Business prepares executives and entrepreneurs for the demands of the modern digital economy.",
|
||||
"credits": 45,
|
||||
"format": "100% Online",
|
||||
"coreCourses": [
|
||||
{ "id": "MBA501", "title": "MBA501: Digital Transformation", "description": "Leading organizational change in the digital era.", "icon": "fa-solid fa-chart-line" }
|
||||
],
|
||||
"electives": ["Digital Marketing", "Fintech Innovations"],
|
||||
"outcomes": [
|
||||
{ "title": "Leadership", "description": "Lead digital transformation initiatives.", "icon": "fa-solid fa-users" }
|
||||
],
|
||||
"faqs": [
|
||||
{ "question": "Do I need a GMAT?", "answer": "No, we have a holistic review process that does not require standardized tests." }
|
||||
],
|
||||
"nextStartDate": "October 15th",
|
||||
"monthlyCost": "$450",
|
||||
"perCourseCost": "$1200"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
const courseSchema = new mongoose.Schema(
|
||||
{
|
||||
id: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
icon: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const outcomeSchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
icon: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const faqSchema = new mongoose.Schema(
|
||||
{
|
||||
question: { type: String, default: "" },
|
||||
answer: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const programmeSchema = new mongoose.Schema(
|
||||
{
|
||||
id: { type: String, required: true, unique: true, trim: true },
|
||||
level: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
duration: { type: String, default: "" },
|
||||
cost: { type: String, default: "" },
|
||||
selected: { type: Boolean, default: false },
|
||||
link: { type: String, default: "" },
|
||||
shortName: { type: String, default: "" },
|
||||
detailBadge: { type: String, default: "" },
|
||||
detailTitle: { type: String, default: "" },
|
||||
detailDescription: { type: String, default: "" },
|
||||
heroImage: { type: String, default: "" },
|
||||
overview: { type: String, default: "" },
|
||||
credits: { type: Number, default: 0 },
|
||||
format: { type: String, default: "100% Online" },
|
||||
nextStartDate: { type: String, default: "" },
|
||||
monthlyCost: { type: String, default: "" },
|
||||
perCourseCost: { type: String, default: "" },
|
||||
coreCourses: { type: [courseSchema], default: [] },
|
||||
electives: { type: [String], default: [] },
|
||||
outcomes: { type: [outcomeSchema], default: [] },
|
||||
faqs: { type: [faqSchema], default: [] },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* Upsert from a JSON array. Used by migration scripts.
|
||||
*/
|
||||
programmeSchema.statics.migrateFromJson = async function (jsonArray) {
|
||||
const results = [];
|
||||
for (const item of jsonArray) {
|
||||
const existing = await this.findOne({ id: item.id });
|
||||
if (existing) {
|
||||
existing.set(item);
|
||||
await existing.save();
|
||||
results.push({ action: "updated", id: item.id });
|
||||
} else {
|
||||
await this.create(item);
|
||||
results.push({ action: "created", id: item.id });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
module.exports = mongoose.model("Programme", programmeSchema);
|
||||
@@ -26,6 +26,7 @@ const activityController = require("../controllers/activityController");
|
||||
const bookingSubmissionController = require("../controllers/bookingSubmissionController");
|
||||
const serviceController = require("../controllers/serviceController");
|
||||
const headerMenuController = require("../controllers/headerMenuController");
|
||||
const programmeController = require("../controllers/programmeController");
|
||||
|
||||
// Blog controllers
|
||||
const blogController = require("../controllers/blogController");
|
||||
@@ -523,6 +524,13 @@ router.delete(
|
||||
ensureAuthenticated,
|
||||
visaController.deleteCountry,
|
||||
);
|
||||
|
||||
// Programme Management
|
||||
router.get("/programme", ensureAuthenticated, programmeController.index);
|
||||
router.get("/programme/edit/:id", ensureAuthenticated, programmeController.edit);
|
||||
router.post("/programme/update/:id", ensureAuthenticated, upload.single('heroImage'), programmeController.update);
|
||||
router.post("/programme/delete/:id", ensureAuthenticated, programmeController.delete);
|
||||
|
||||
// Blog routes
|
||||
// Blog Management Routes
|
||||
router.get("/blog", ensureAuthenticated, blogController.index);
|
||||
|
||||
@@ -15,6 +15,7 @@ const headerMenuController = require("../controllers/headerMenuController");
|
||||
const safetyController = require("../controllers/safetyController");
|
||||
// Booking flow removed
|
||||
|
||||
const programmeController = require("../controllers/programmeController");
|
||||
const insuranceController = require("../controllers/insuranceController");
|
||||
const termsController = require("../controllers/termsController"); // <-- IMPORT ĐÃ CÓ
|
||||
const activityController = require("../controllers/activityController");
|
||||
@@ -183,6 +184,10 @@ router.get("/api/service-slugs", serviceController.getServiceSlugs);
|
||||
router.get("/api/visa", visaController.api);
|
||||
router.get("/api/visa/country", visaController.apiCountries);
|
||||
|
||||
// Programmes API
|
||||
router.get("/api/programmes", programmeController.api);
|
||||
router.get("/api/programmes/:id", programmeController.apiDetail);
|
||||
|
||||
// Testimonials API
|
||||
const testimonialController = require("../controllers/testimonialController");
|
||||
router.get("/api/testimonials", testimonialController.api);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
require("dotenv").config();
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const mongoose = require("mongoose");
|
||||
const connectDB = require("../config/database");
|
||||
const Programme = require("../models/programme");
|
||||
|
||||
/**
|
||||
* Seed / upsert all programmes from data/programmes.json into MongoDB.
|
||||
* Run: node scripts/2026_04_20_150000_seed_programmes.js
|
||||
*/
|
||||
async function migrate() {
|
||||
try {
|
||||
await connectDB();
|
||||
console.log("Connected to MongoDB");
|
||||
|
||||
const jsonPath = path.join(__dirname, "../data/programmes.json");
|
||||
const raw = await fs.readFile(jsonPath, "utf8");
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("data/programmes.json must be a JSON array");
|
||||
}
|
||||
|
||||
const results = await Programme.migrateFromJson(data);
|
||||
console.log("Programme migration completed:");
|
||||
results.forEach((r) => console.log(` [${r.action}] id=${r.id}`));
|
||||
|
||||
await mongoose.disconnect();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Programme migration error:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
migrate();
|
||||
}
|
||||
|
||||
module.exports = { migrate };
|
||||
@@ -0,0 +1,53 @@
|
||||
require('dotenv').config();
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const mongoose = require('mongoose');
|
||||
const connectDB = require('../config/database');
|
||||
const Programme = require('../models/programme');
|
||||
|
||||
async function validateProgrammeData(dataArray) {
|
||||
if (!Array.isArray(dataArray)) {
|
||||
throw new Error('Data must be an array of programme objects');
|
||||
}
|
||||
|
||||
if (dataArray.length === 0) {
|
||||
throw new Error('Programme array cannot be empty');
|
||||
}
|
||||
|
||||
for (const item of dataArray) {
|
||||
if (!item.id || !item.title || !item.level) {
|
||||
throw new Error(`Programme is missing required fields (id, title, level). Error at id: ${item.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateProgrammeData() {
|
||||
try {
|
||||
await connectDB();
|
||||
console.log('Đã kết nối đến MongoDB...');
|
||||
|
||||
await Programme.deleteMany({});
|
||||
console.log('Đã xóa dữ liệu Programme cũ');
|
||||
|
||||
const programmesData = JSON.parse(
|
||||
await fs.readFile(path.join(__dirname, '../data/programmes.json'), 'utf8')
|
||||
);
|
||||
|
||||
await validateProgrammeData(programmesData);
|
||||
|
||||
const dataWithTimestamps = programmesData.map(item => ({
|
||||
...item,
|
||||
updatedAt: new Date()
|
||||
}));
|
||||
|
||||
await Programme.insertMany(dataWithTimestamps);
|
||||
console.log(`✓ Migrate dữ liệu Programmes thành công (${dataWithTimestamps.length} items)!`);
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Lỗi:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrateProgrammeData();
|
||||
@@ -234,6 +234,25 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Programme -->
|
||||
<div class="col-md-4 border-top">
|
||||
<div class="p-4">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
|
||||
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
|
||||
<i class="fas fa-graduation-cap fa-lg" style="color: var(--primary-color);"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-0">Programmes</h5>
|
||||
<p class="text-muted mb-0 small">Manage academic programmes</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/admin/programme" class="btn btn-sm btn-primary w-100 mt-2">
|
||||
<i class="fas fa-edit me-2"></i>Manage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,7 +261,7 @@
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">API Endpoints</h5>
|
||||
<span class="badge bg-primary">7 APIs</span>
|
||||
<span class="badge bg-primary">9 APIs</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
@@ -421,13 +440,55 @@
|
||||
<td>
|
||||
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
||||
</td>
|
||||
<td>API to get blog posts</td>
|
||||
<td>List published blog posts (supports ?page, ?category, ?search)</td>
|
||||
<td>
|
||||
<a href="/api/blog" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
|
||||
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
|
||||
<i class="fas fa-graduation-cap" style="color: var(--primary-color);"></i>
|
||||
</div>
|
||||
<span>Programmes API</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/programmes</code></td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
||||
</td>
|
||||
<td>List all academic programmes</td>
|
||||
<td>
|
||||
<a href="/api/programmes" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
|
||||
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
|
||||
<i class="fas fa-graduation-cap" style="color: var(--primary-color);"></i>
|
||||
</div>
|
||||
<span>Programme Detail API</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>/api/programmes/:id</code></td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: var(--primary-color)">GET</span>
|
||||
</td>
|
||||
<td>Single programme by slug ID</td>
|
||||
<td>
|
||||
<a href="/api/programmes" class="btn btn-sm btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-1"></i>View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark);"><%= title %></h1>
|
||||
<p class="text-muted mb-0">Fields marked with <span class="text-danger">*</span> are required.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<% if (programme._id && frontendUrl) { %>
|
||||
<a href="<%= frontendUrl %>/programmes/<%= programme.id %>" class="btn btn-outline-primary" target="_blank" rel="noopener">
|
||||
<i class="fas fa-external-link-alt me-2"></i>Preview
|
||||
</a>
|
||||
<% } %>
|
||||
<a href="/admin/programme" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-2"></i>Back to list
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
action="/admin/programme/update/<%= programme._id ? programme._id : 'new' %>?imageType=programmes"
|
||||
method="POST"
|
||||
enctype="multipart/form-data"
|
||||
id="programmeForm"
|
||||
class="content-with-fixed-buttons"
|
||||
>
|
||||
<%# Hidden JSON inputs for dynamic arrays %>
|
||||
<input type="hidden" name="coreCourses" id="coreCoursesJson">
|
||||
<input type="hidden" name="outcomes" id="outcomesJson">
|
||||
<input type="hidden" name="faqs" id="faqsJson">
|
||||
<input type="hidden" name="electives" id="electivesJson">
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-bs-toggle="tab" href="#tab-basic" role="tab">
|
||||
<i class="fas fa-info-circle me-2"></i>Basic Info
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#tab-detail" role="tab">
|
||||
<i class="fas fa-file-alt me-2"></i>Detail Page
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#tab-curriculum" role="tab">
|
||||
<i class="fas fa-book me-2"></i>Curriculum
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#tab-outcomes" role="tab">
|
||||
<i class="fas fa-star me-2"></i>Outcomes
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#tab-faq" role="tab">
|
||||
<i class="fas fa-question-circle me-2"></i>FAQs
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#tab-pricing" role="tab">
|
||||
<i class="fas fa-dollar-sign me-2"></i>Pricing
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
|
||||
<%# ─── TAB 1: Basic Info ─────────────────────────────────────────── %>
|
||||
<div class="tab-pane fade show active" id="tab-basic" role="tabpanel">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium" for="fieldId">ID / Code <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="fieldId" name="id" value="<%= programme.id || '' %>" required placeholder="e.g. bs-cs, mba-db">
|
||||
<div class="form-text">Dùng làm URL slug: /programmes/<strong>bs-cs</strong></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium" for="fieldLevel">Level</label>
|
||||
<input type="text" class="form-control" id="fieldLevel" name="level" value="<%= programme.level || '' %>" placeholder="Bachelor's Degree, Master's…">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium" for="fieldShortName">Short Name</label>
|
||||
<input type="text" class="form-control" id="fieldShortName" name="shortName" value="<%= programme.shortName || '' %>" placeholder="MBA, B.S., Cert…">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium" for="fieldTitle">Title <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="fieldTitle" name="title" value="<%= programme.title || '' %>" required>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium" for="fieldDescription">Description (Listing card)</label>
|
||||
<textarea class="form-control" id="fieldDescription" name="description" rows="3"><%= programme.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium" for="fieldDuration">Duration</label>
|
||||
<input type="text" class="form-control" id="fieldDuration" name="duration" value="<%= programme.duration || '' %>" placeholder="4 Years (Flexible)">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium" for="fieldFormat">Format</label>
|
||||
<input type="text" class="form-control" id="fieldFormat" name="format" value="<%= programme.format || '100% Online' %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium" for="fieldLink">Public Link</label>
|
||||
<input type="text" class="form-control" id="fieldLink" name="link" value="<%= programme.link || '' %>" placeholder="/programmes/bs-cs">
|
||||
<div class="form-text">Tự sinh từ ID nếu để trống. Hiện tại: <code id="linkPreview">/programmes/<%= programme.id || '…' %></code></div>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="fieldSelected" name="selected" value="true" <%= programme.selected ? 'checked' : '' %>>
|
||||
<label class="form-check-label" for="fieldSelected">
|
||||
<i class="fas fa-star text-warning me-1"></i>Featured / Selected (hiển thị nổi bật trên listing)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ─── TAB 2: Detail Page ────────────────────────────────────────── %>
|
||||
<div class="tab-pane fade" id="tab-detail" role="tabpanel">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium" for="fieldDetailBadge">Detail Badge</label>
|
||||
<input type="text" class="form-control" id="fieldDetailBadge" name="detailBadge" value="<%= programme.detailBadge || '' %>" placeholder="Bachelor of Science">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium" for="fieldDetailTitle">Detail Title</label>
|
||||
<input type="text" class="form-control" id="fieldDetailTitle" name="detailTitle" value="<%= programme.detailTitle || '' %>" placeholder="Computer Science">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium" for="fieldDetailDescription">Detail Description</label>
|
||||
<textarea class="form-control" id="fieldDetailDescription" name="detailDescription" rows="3"><%= programme.detailDescription || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-medium" for="fieldOverview">Overview (main body text)</label>
|
||||
<textarea class="form-control" id="fieldOverview" name="overview" rows="5"><%= programme.overview || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium" for="fieldCredits">Total Credits</label>
|
||||
<input type="number" class="form-control" id="fieldCredits" name="credits" value="<%= programme.credits || 120 %>" min="0">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium" for="fieldNextStartDate">Next Start Date</label>
|
||||
<input type="text" class="form-control" id="fieldNextStartDate" name="nextStartDate" value="<%= programme.nextStartDate || '' %>" placeholder="September 1st">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<% /* placeholder col */ %>
|
||||
</div>
|
||||
|
||||
<%# Hero Image Upload %>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-medium" for="heroImageUrl">Hero Image URL</label>
|
||||
<div class="input-group mb-2">
|
||||
<input type="text" class="form-control" id="heroImageUrl" name="heroImageUrl" value="<%= programme.heroImage || '' %>">
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image" data-target-input="heroImageUrl" data-image-type="programmes">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted">Hoặc upload file: </small>
|
||||
<input type="file" name="heroImage" class="form-control form-control-sm mt-1" accept="image/*" id="heroImageFile">
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div id="heroImagePreview" class="border rounded overflow-hidden bg-light w-100" style="height:100px;">
|
||||
<% const hi = programme.heroImage; if (hi) { %>
|
||||
<% let src = hi; if (!src.startsWith('http')) { src = src.startsWith('/') ? src : '/' + src; } %>
|
||||
<img src="<%= src %>" alt="" class="w-100 h-100" style="object-fit:cover;" id="heroPreviewImg" onerror="this.style.display='none'">
|
||||
<% } else { %>
|
||||
<div class="h-100 d-flex align-items-center justify-content-center text-muted small">
|
||||
<div class="text-center"><i class="fas fa-image fa-2x mb-1 d-block opacity-25"></i>No image</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ─── TAB 3: Curriculum ─────────────────────────────────────────── %>
|
||||
<div class="tab-pane fade" id="tab-curriculum" role="tabpanel">
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h6 class="mb-0">Core Courses</h6>
|
||||
<small class="text-muted">Môn học bắt buộc trong chương trình</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="addCourseBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add Course
|
||||
</button>
|
||||
</div>
|
||||
<div id="coursesContainer" class="vstack gap-3">
|
||||
<% (programme.coreCourses || []).forEach(function(course, idx) { %>
|
||||
<div class="card course-row border">
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Course ID</label>
|
||||
<input type="text" class="form-control course-id" value="<%= course.id || '' %>" placeholder="CS101">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" class="form-control course-title" value="<%= course.title || '' %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Icon</label>
|
||||
<select class="form-select course-icon-select">
|
||||
<option value="">— No icon —</option>
|
||||
<% iconOptions.forEach(function(opt) { %>
|
||||
<option value="<%= opt.value %>" <%= (course.icon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm remove-course">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label small">Description</label>
|
||||
<input type="text" class="form-control course-description" value="<%= course.description || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h6 class="mb-0">Electives</h6>
|
||||
<small class="text-muted">Môn học tự chọn</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="addElectiveBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add Elective
|
||||
</button>
|
||||
</div>
|
||||
<div id="electivesContainer" class="vstack gap-2">
|
||||
<% (programme.electives || []).forEach(function(elective) { %>
|
||||
<div class="input-group elective-row">
|
||||
<input type="text" class="form-control elective-input" value="<%= elective %>">
|
||||
<button type="button" class="btn btn-outline-danger remove-elective"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ─── TAB 4: Outcomes ───────────────────────────────────────────── %>
|
||||
<div class="tab-pane fade" id="tab-outcomes" role="tabpanel">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h6 class="mb-0">Learning Outcomes</h6>
|
||||
<small class="text-muted">Những gì sinh viên đạt được sau khoá học</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="addOutcomeBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add Outcome
|
||||
</button>
|
||||
</div>
|
||||
<div id="outcomesContainer" class="vstack gap-3">
|
||||
<% (programme.outcomes || []).forEach(function(outcome) { %>
|
||||
<div class="card outcome-row border">
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" class="form-control outcome-title" value="<%= outcome.title || '' %>">
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small">Icon</label>
|
||||
<select class="form-select outcome-icon-select">
|
||||
<option value="">— No icon —</option>
|
||||
<% iconOptions.forEach(function(opt) { %>
|
||||
<option value="<%= opt.value %>" <%= (outcome.icon === opt.value) ? 'selected' : '' %>><%= opt.label %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-1 d-flex align-items-end justify-content-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm remove-outcome"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label small">Description</label>
|
||||
<textarea class="form-control outcome-description" rows="2"><%= outcome.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ─── TAB 5: FAQs ───────────────────────────────────────────────── %>
|
||||
<div class="tab-pane fade" id="tab-faq" role="tabpanel">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h6 class="mb-0">Frequently Asked Questions</h6>
|
||||
<small class="text-muted">Câu hỏi thường gặp trên trang chi tiết chương trình</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="addFaqBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add FAQ
|
||||
</button>
|
||||
</div>
|
||||
<div id="faqsContainer" class="vstack gap-3">
|
||||
<% (programme.faqs || []).forEach(function(faq) { %>
|
||||
<div class="card faq-row border">
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-11">
|
||||
<label class="form-label small">Question</label>
|
||||
<input type="text" class="form-control faq-question" value="<%= faq.question || '' %>">
|
||||
</div>
|
||||
<div class="col-md-1 d-flex align-items-end justify-content-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm remove-faq"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label small">Answer</label>
|
||||
<textarea class="form-control faq-answer" rows="2"><%= faq.answer || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ─── TAB 6: Pricing ────────────────────────────────────────────── %>
|
||||
<div class="tab-pane fade" id="tab-pricing" role="tabpanel">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium" for="fieldCost">Cost (hiển thị listing)</label>
|
||||
<input type="text" class="form-control" id="fieldCost" name="cost" value="<%= programme.cost || '' %>" placeholder="$299 / month">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium" for="fieldMonthlyCost">Monthly Cost (sidebar)</label>
|
||||
<input type="text" class="form-control" id="fieldMonthlyCost" name="monthlyCost" value="<%= programme.monthlyCost || '' %>" placeholder="$299">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-medium" for="fieldPerCourseCost">Per Course Cost (sidebar)</label>
|
||||
<input type="text" class="form-control" id="fieldPerCourseCost" name="perCourseCost" value="<%= programme.perCourseCost || '' %>" placeholder="$450">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><%# end .tab-content %>
|
||||
</div><%# end .card-body %>
|
||||
</div><%# end .card %>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<a href="/admin/programme" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<i class="fas fa-save me-2"></i>Save Programme
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.PROGRAMME_ICON_OPTIONS = <%- JSON.stringify(iconOptions || []) %>;
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function iconOptionsHtml(selected) {
|
||||
var opts = window.PROGRAMME_ICON_OPTIONS;
|
||||
var html = '<option value="">— No icon —</option>';
|
||||
for (var i = 0; i < opts.length; i++) {
|
||||
var o = opts[i];
|
||||
var sel = (o.value === selected) ? ' selected' : '';
|
||||
html += '<option value="' + escHtml(o.value) + '"' + sel + '>' + escHtml(o.label) + '</option>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildCourseRow(c) {
|
||||
c = c || {};
|
||||
return (
|
||||
'<div class="card course-row border">' +
|
||||
'<div class="card-body">' +
|
||||
'<div class="row g-2">' +
|
||||
'<div class="col-md-2"><label class="form-label small">Course ID</label>' +
|
||||
'<input type="text" class="form-control course-id" value="' + escHtml(c.id) + '" placeholder="CS101"></div>' +
|
||||
'<div class="col-md-4"><label class="form-label small">Title</label>' +
|
||||
'<input type="text" class="form-control course-title" value="' + escHtml(c.title) + '"></div>' +
|
||||
'<div class="col-md-4"><label class="form-label small">Icon</label>' +
|
||||
'<select class="form-select course-icon-select"><' + 'option value="">— No icon —</' + 'option>' + iconOptionsHtml(c.icon) + '</select></div>' +
|
||||
'<div class="col-md-2 d-flex align-items-end justify-content-end">' +
|
||||
'<button type="button" class="btn btn-outline-danger btn-sm remove-course"><i class="fas fa-trash"></i></button></div>' +
|
||||
'<div class="col-md-12"><label class="form-label small">Description</label>' +
|
||||
'<input type="text" class="form-control course-description" value="' + escHtml(c.description) + '"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function buildOutcomeRow(o) {
|
||||
o = o || {};
|
||||
return (
|
||||
'<div class="card outcome-row border">' +
|
||||
'<div class="card-body">' +
|
||||
'<div class="row g-2">' +
|
||||
'<div class="col-md-6"><label class="form-label small">Title</label>' +
|
||||
'<input type="text" class="form-control outcome-title" value="' + escHtml(o.title) + '"></div>' +
|
||||
'<div class="col-md-5"><label class="form-label small">Icon</label>' +
|
||||
'<select class="form-select outcome-icon-select"><option value="">— No icon —</option>' + iconOptionsHtml(o.icon) + '</select></div>' +
|
||||
'<div class="col-md-1 d-flex align-items-end justify-content-end">' +
|
||||
'<button type="button" class="btn btn-outline-danger btn-sm remove-outcome"><i class="fas fa-trash"></i></button></div>' +
|
||||
'<div class="col-md-12"><label class="form-label small">Description</label>' +
|
||||
'<textarea class="form-control outcome-description" rows="2">' + escHtml(o.description) + '</textarea></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function buildFaqRow(f) {
|
||||
f = f || {};
|
||||
return (
|
||||
'<div class="card faq-row border">' +
|
||||
'<div class="card-body">' +
|
||||
'<div class="row g-2">' +
|
||||
'<div class="col-md-11"><label class="form-label small">Question</label>' +
|
||||
'<input type="text" class="form-control faq-question" value="' + escHtml(f.question) + '"></div>' +
|
||||
'<div class="col-md-1 d-flex align-items-end justify-content-end">' +
|
||||
'<button type="button" class="btn btn-outline-danger btn-sm remove-faq"><i class="fas fa-trash"></i></button></div>' +
|
||||
'<div class="col-md-12"><label class="form-label small">Answer</label>' +
|
||||
'<textarea class="form-control faq-answer" rows="2">' + escHtml(f.answer) + '</textarea></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function updateHeroPreview(url) {
|
||||
var el = document.getElementById('heroImagePreview');
|
||||
if (!el) return;
|
||||
if (!url) {
|
||||
el.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-muted small"><div class="text-center"><i class="fas fa-image fa-2x mb-1 d-block opacity-25"></i>No image</div></div>';
|
||||
return;
|
||||
}
|
||||
var src = url;
|
||||
if (!src.startsWith('http://') && !src.startsWith('https://')) {
|
||||
src = src.startsWith('/') ? src : '/' + src;
|
||||
}
|
||||
el.innerHTML = '<img src="' + escHtml(src) + '" alt="" class="w-100 h-100" style="object-fit:cover;">';
|
||||
}
|
||||
|
||||
function openImageUploader(targetInputId, imageType) {
|
||||
var fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.onchange = async function (e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
var formData = new FormData();
|
||||
formData.append('image', file);
|
||||
var uploadBtn = document.querySelector('[data-target-input="' + targetInputId + '"]');
|
||||
var original = uploadBtn ? uploadBtn.innerHTML : '';
|
||||
if (uploadBtn) { uploadBtn.disabled = true; uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>...'; }
|
||||
var res = await fetch('/admin/upload/image?imageType=' + encodeURIComponent(imageType || 'general'), {
|
||||
method: 'POST', body: formData,
|
||||
});
|
||||
var result = await res.json();
|
||||
if (!result.success) throw new Error(result.error || 'Upload failed');
|
||||
var input = document.getElementById(targetInputId);
|
||||
if (input) { input.value = result.path || ''; updateHeroPreview(input.value); }
|
||||
if (uploadBtn) { uploadBtn.disabled = false; uploadBtn.innerHTML = original; }
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Upload failed: ' + (err.message || err));
|
||||
var b = document.querySelector('[data-target-input="' + targetInputId + '"]');
|
||||
if (b) { b.disabled = false; b.innerHTML = '<i class="fas fa-upload me-1"></i>Upload'; }
|
||||
} finally {
|
||||
document.body.removeChild(fileInput);
|
||||
}
|
||||
};
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function syncHiddenJson() {
|
||||
// Core Courses
|
||||
var courses = [];
|
||||
document.querySelectorAll('#coursesContainer .course-row').forEach(function(row) {
|
||||
courses.push({
|
||||
id: row.querySelector('.course-id').value.trim(),
|
||||
title: row.querySelector('.course-title').value.trim(),
|
||||
description: row.querySelector('.course-description').value.trim(),
|
||||
icon: row.querySelector('.course-icon-select').value,
|
||||
});
|
||||
});
|
||||
document.getElementById('coreCoursesJson').value = JSON.stringify(courses);
|
||||
|
||||
// Outcomes
|
||||
var outcomes = [];
|
||||
document.querySelectorAll('#outcomesContainer .outcome-row').forEach(function(row) {
|
||||
outcomes.push({
|
||||
title: row.querySelector('.outcome-title').value.trim(),
|
||||
description: row.querySelector('.outcome-description').value.trim(),
|
||||
icon: row.querySelector('.outcome-icon-select').value,
|
||||
});
|
||||
});
|
||||
document.getElementById('outcomesJson').value = JSON.stringify(outcomes);
|
||||
|
||||
// FAQs
|
||||
var faqs = [];
|
||||
document.querySelectorAll('#faqsContainer .faq-row').forEach(function(row) {
|
||||
faqs.push({
|
||||
question: row.querySelector('.faq-question').value.trim(),
|
||||
answer: row.querySelector('.faq-answer').value.trim(),
|
||||
});
|
||||
});
|
||||
document.getElementById('faqsJson').value = JSON.stringify(faqs);
|
||||
|
||||
// Electives — gửi dạng [{value: "..."}, ...] giống student-support
|
||||
var electives = [];
|
||||
document.querySelectorAll('#electivesContainer .elective-input').forEach(function(inp) {
|
||||
var v = inp.value.trim();
|
||||
if (v) electives.push({ value: v });
|
||||
});
|
||||
document.getElementById('electivesJson').value = JSON.stringify(electives);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var form = document.getElementById('programmeForm');
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving…';
|
||||
try {
|
||||
syncHiddenJson();
|
||||
form.submit();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Could not prepare form data. Please try again.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-save me-2"></i>Save Programme';
|
||||
}
|
||||
});
|
||||
|
||||
// Upload image buttons
|
||||
document.querySelectorAll('.btn-upload-image').forEach(function(btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
openImageUploader(this.dataset.targetInput, this.dataset.imageType);
|
||||
});
|
||||
});
|
||||
|
||||
// Hero image preview sync on text input
|
||||
var heroInput = document.getElementById('heroImageUrl');
|
||||
if (heroInput) {
|
||||
heroInput.addEventListener('input', function () { updateHeroPreview(this.value); });
|
||||
}
|
||||
|
||||
// Auto-sync link preview khi admin gõ ID
|
||||
var fieldId = document.getElementById('fieldId');
|
||||
var linkPreview = document.getElementById('linkPreview');
|
||||
var fieldLink = document.getElementById('fieldLink');
|
||||
if (fieldId && linkPreview) {
|
||||
fieldId.addEventListener('input', function () {
|
||||
var slug = this.value.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
linkPreview.textContent = '/programmes/' + (slug || '…');
|
||||
// Nếu field link đang rỗng, tự động điền slug
|
||||
if (fieldLink && !fieldLink.value) {
|
||||
fieldLink.placeholder = '/programmes/' + (slug || 'your-id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add buttons
|
||||
document.getElementById('addCourseBtn').addEventListener('click', function () {
|
||||
document.getElementById('coursesContainer').insertAdjacentHTML('beforeend', buildCourseRow({}));
|
||||
});
|
||||
document.getElementById('addOutcomeBtn').addEventListener('click', function () {
|
||||
document.getElementById('outcomesContainer').insertAdjacentHTML('beforeend', buildOutcomeRow({}));
|
||||
});
|
||||
document.getElementById('addFaqBtn').addEventListener('click', function () {
|
||||
document.getElementById('faqsContainer').insertAdjacentHTML('beforeend', buildFaqRow({}));
|
||||
});
|
||||
document.getElementById('addElectiveBtn').addEventListener('click', function () {
|
||||
document.getElementById('electivesContainer').insertAdjacentHTML('beforeend',
|
||||
'<div class="input-group elective-row">' +
|
||||
'<input type="text" class="form-control elective-input" value="">' +
|
||||
'<button type="button" class="btn btn-outline-danger remove-elective"><i class="fas fa-times"></i></button>' +
|
||||
'</div>'
|
||||
);
|
||||
});
|
||||
|
||||
// Remove buttons (event delegation)
|
||||
document.getElementById('coursesContainer').addEventListener('click', function(e) {
|
||||
if (e.target.closest('.remove-course')) e.target.closest('.course-row').remove();
|
||||
});
|
||||
document.getElementById('outcomesContainer').addEventListener('click', function(e) {
|
||||
if (e.target.closest('.remove-outcome')) e.target.closest('.outcome-row').remove();
|
||||
});
|
||||
document.getElementById('faqsContainer').addEventListener('click', function(e) {
|
||||
if (e.target.closest('.remove-faq')) e.target.closest('.faq-row').remove();
|
||||
});
|
||||
document.getElementById('electivesContainer').addEventListener('click', function(e) {
|
||||
if (e.target.closest('.remove-elective')) e.target.closest('.elective-row').remove();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark);">Programmes Management</h1>
|
||||
<p class="text-muted mb-0">Manage all academic programmes displayed on the public website.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<% if (frontendUrl) { %>
|
||||
<a href="<%= frontendUrl %>/programmes" class="btn btn-outline-primary" target="_blank" rel="noopener">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Programmes Page
|
||||
</a>
|
||||
<% } %>
|
||||
<a href="/admin/programme/edit/new" class="btn btn-primary shadow-sm">
|
||||
<i class="fas fa-plus me-2"></i>Add New Programme
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-4" style="width:110px;">ID / Code</th>
|
||||
<th>Title</th>
|
||||
<th>Level</th>
|
||||
<th>Format</th>
|
||||
<th>Cost</th>
|
||||
<th class="text-center" style="width:80px;">Courses</th>
|
||||
<th class="text-center" style="width:80px;">Featured</th>
|
||||
<th class="text-end pe-4" style="width:120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (data && data.length > 0) { %>
|
||||
<% data.forEach(function(item) { %>
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<code class="text-primary"><%= item.id %></code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="fw-medium"><%= item.title %></span>
|
||||
<% if (item.description) { %>
|
||||
<div class="text-muted small text-truncate" style="max-width:280px;"><%= item.description %></div>
|
||||
<% } %>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border"><%= item.level || '—' %></span>
|
||||
</td>
|
||||
<td class="text-muted small"><%= item.format || '—' %></td>
|
||||
<td class="text-muted small"><%= item.monthlyCost || item.cost || '—' %></td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-secondary rounded-pill"><%= (item.coreCourses || []).length %></span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<% if (item.selected) { %>
|
||||
<i class="fas fa-star text-warning" title="Featured"></i>
|
||||
<% } else { %>
|
||||
<i class="far fa-star text-muted" title="Not featured"></i>
|
||||
<% } %>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<a href="/admin/programme/edit/<%= item._id %>" class="btn btn-sm btn-outline-primary me-1" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<form action="/admin/programme/delete/<%= item._id %>" method="POST" class="d-inline"
|
||||
onsubmit="return confirm('Delete programme \'<%= item.title %>\'? This cannot be undone.');">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
<% } else { %>
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted py-5">
|
||||
<i class="fas fa-graduation-cap fa-2x mb-3 d-block opacity-25"></i>
|
||||
No programmes found.
|
||||
<a href="/admin/programme/edit/new" class="d-block mt-2">Add your first programme →</a>
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,6 +96,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/upload">Upload</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/programme">Programmes</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -750,6 +750,9 @@
|
||||
<a class="nav-link <%= currentPath === '/admin/pricing' ? 'active' : '' %>"
|
||||
href="/admin/pricing">Pricing</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/programme">Programmes</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= currentPath === '/admin/audit-logs' ? 'active' : '' %>"
|
||||
href="/admin/audit-logs">Audit Log
|
||||
|
||||
Reference in New Issue
Block a user