forked from UKSOURCE/cms.lams
feat(cms): refactor page content management and implement detailed editors
Refactor the CMS page content system to use a more modular configuration-driven approach. This replaces the monolithic `pageContentConfig.js` with individual configuration files for each page and introduces a shared field utility to standardize editor components. Key changes: - Implement a generic `_renderSingletonPageView` helper to reduce duplication across controllers. - Enhance `_createPageContentController` with `normalizeForEditor`, `normalizeForApi`, and `preparePayload` hooks for custom data transformation. - Create dedicated configuration and view structures for Partnerships, History, Accreditation, Admissions, and Policies pages. - Add a specialized section editor for Policies to manage complex nested content. - Improve the frontend `page-content-editor.js` with support for visibility logic, auto-sequencing for arrays, and URL synchronization for active tabs. - Update data JSON files to align with the new schema.
This commit is contained in:
@@ -22,3 +22,4 @@ pids
|
||||
#cursor
|
||||
.cursor
|
||||
package-lock.json
|
||||
AGENTS.md
|
||||
|
||||
@@ -8,12 +8,16 @@ function createPageContentController({
|
||||
modelName,
|
||||
auditAction,
|
||||
editorConfig,
|
||||
normalizeForEditor,
|
||||
normalizeForApi,
|
||||
preparePayload,
|
||||
}) {
|
||||
return {
|
||||
async index(req, res) {
|
||||
try {
|
||||
const doc = await model.getSingle();
|
||||
const data = doc.toObject();
|
||||
const rawData = doc.toObject();
|
||||
const data = normalizeForEditor ? normalizeForEditor(rawData, req) : rawData;
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const backendUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
@@ -40,7 +44,7 @@ function createPageContentController({
|
||||
|
||||
async update(req, res) {
|
||||
try {
|
||||
const payload =
|
||||
const rawPayload =
|
||||
typeof req.body.pageJson === "string"
|
||||
? JSON.parse(req.body.pageJson)
|
||||
: req.body.pageJson || {};
|
||||
@@ -48,6 +52,9 @@ function createPageContentController({
|
||||
const activeTab = req.body.activeTab || editorConfig.tabs[0].key;
|
||||
const doc = await model.getSingle();
|
||||
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
const payload = preparePayload
|
||||
? preparePayload(rawPayload, { req, doc, beforeData })
|
||||
: rawPayload;
|
||||
|
||||
doc.set(payload);
|
||||
Object.keys(payload).forEach((key) => doc.markModified(key));
|
||||
@@ -98,7 +105,8 @@ function createPageContentController({
|
||||
const rawData = doc.toObject();
|
||||
const backendUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const processed = addBaseUrlToImages(rawData, backendUrl);
|
||||
const normalized = normalizeForApi ? normalizeForApi(rawData, req) : rawData;
|
||||
const processed = addBaseUrlToImages(normalized, backendUrl);
|
||||
return res.json(processed);
|
||||
} catch (error) {
|
||||
console.error(`${editorConfig.key} api error:`, error);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
function createRenderSingletonPageView({
|
||||
model,
|
||||
editorConfig,
|
||||
view,
|
||||
normalizeForEditor,
|
||||
}) {
|
||||
return async function renderSingletonPageView(req, res) {
|
||||
const doc = await model.getSingle();
|
||||
const rawData = doc.toObject();
|
||||
const data = normalizeForEditor ? normalizeForEditor(rawData, req) : rawData;
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const backendUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
return res.render(view, {
|
||||
layout: "layouts/main",
|
||||
title: editorConfig.title,
|
||||
subtitle: editorConfig.subtitle,
|
||||
data,
|
||||
editorConfig,
|
||||
activeTab: req.query.tab || editorConfig.tabs[0].key,
|
||||
frontendUrl,
|
||||
backendUrl,
|
||||
previewUrl: `${frontendUrl}${editorConfig.previewPath}`,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = createRenderSingletonPageView;
|
||||
@@ -1,11 +1,28 @@
|
||||
const AccreditationPage = require("../models/accreditationPage");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const pageContentConfig = require("../utils/pageContentConfig");
|
||||
const accreditationConfig = require("../utils/contentEditors/accreditationConfig");
|
||||
const createPageContentController = require("./_createPageContentController");
|
||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
||||
|
||||
module.exports = createPageContentController({
|
||||
const controller = createPageContentController({
|
||||
model: AccreditationPage,
|
||||
modelName: "AccreditationPage",
|
||||
auditAction: AUDIT_ACTIONS.UPDATE_ACCREDITATION,
|
||||
editorConfig: pageContentConfig.accreditation,
|
||||
editorConfig: accreditationConfig,
|
||||
});
|
||||
|
||||
controller.index = async function index(req, res) {
|
||||
try {
|
||||
return await createRenderSingletonPageView({
|
||||
model: AccreditationPage,
|
||||
editorConfig: accreditationConfig,
|
||||
view: "admin/accreditation/index",
|
||||
})(req, res);
|
||||
} catch (error) {
|
||||
console.error("accreditation index error:", error);
|
||||
req.flash("error_msg", "Error loading Accreditation Management");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = controller;
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
const AdmissionsPage = require("../models/admissionsPage");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const pageContentConfig = require("../utils/pageContentConfig");
|
||||
const admissionsConfig = require("../utils/contentEditors/admissionsConfig");
|
||||
const createPageContentController = require("./_createPageContentController");
|
||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
||||
|
||||
module.exports = createPageContentController({
|
||||
const controller = createPageContentController({
|
||||
model: AdmissionsPage,
|
||||
modelName: "AdmissionsPage",
|
||||
auditAction: AUDIT_ACTIONS.UPDATE_ADMISSIONS,
|
||||
editorConfig: pageContentConfig.admissions,
|
||||
editorConfig: admissionsConfig,
|
||||
});
|
||||
|
||||
controller.index = async function index(req, res) {
|
||||
try {
|
||||
return await createRenderSingletonPageView({
|
||||
model: AdmissionsPage,
|
||||
editorConfig: admissionsConfig,
|
||||
view: "admin/admissions/index",
|
||||
})(req, res);
|
||||
} catch (error) {
|
||||
console.error("admissions index error:", error);
|
||||
req.flash("error_msg", "Error loading Admissions Management");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = controller;
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
const HistoryPage = require("../models/historyPage");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const pageContentConfig = require("../utils/pageContentConfig");
|
||||
const historyConfig = require("../utils/contentEditors/historyConfig");
|
||||
const createPageContentController = require("./_createPageContentController");
|
||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
||||
|
||||
module.exports = createPageContentController({
|
||||
const controller = createPageContentController({
|
||||
model: HistoryPage,
|
||||
modelName: "HistoryPage",
|
||||
auditAction: AUDIT_ACTIONS.UPDATE_HISTORY,
|
||||
editorConfig: pageContentConfig.history,
|
||||
editorConfig: historyConfig,
|
||||
});
|
||||
|
||||
controller.index = async function index(req, res) {
|
||||
try {
|
||||
return await createRenderSingletonPageView({
|
||||
model: HistoryPage,
|
||||
editorConfig: historyConfig,
|
||||
view: "admin/history/index",
|
||||
})(req, res);
|
||||
} catch (error) {
|
||||
console.error("history index error:", error);
|
||||
req.flash("error_msg", "Error loading History Management");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = controller;
|
||||
|
||||
@@ -1,11 +1,102 @@
|
||||
const PartnershipsPage = require("../models/partnerships");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const pageContentConfig = require("../utils/pageContentConfig");
|
||||
const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig");
|
||||
const createPageContentController = require("./_createPageContentController");
|
||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
||||
|
||||
module.exports = createPageContentController({
|
||||
function toInquiryField(id, field, type, width) {
|
||||
return {
|
||||
id,
|
||||
label: field?.label || "",
|
||||
placeholder: field?.placeholder || "",
|
||||
type,
|
||||
width,
|
||||
required: true,
|
||||
options: Array.isArray(field?.options) ? field.options : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInquiryForm(data) {
|
||||
if (!data?.inquiryForm) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.inquiryForm.fields)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const legacyFields = data.inquiryForm.fields || {};
|
||||
|
||||
return {
|
||||
...data,
|
||||
inquiryForm: {
|
||||
...data.inquiryForm,
|
||||
fields: [
|
||||
toInquiryField("firstName", legacyFields.firstName, "text", "half"),
|
||||
toInquiryField("lastName", legacyFields.lastName, "text", "half"),
|
||||
toInquiryField("organization", legacyFields.organization, "text", "full"),
|
||||
toInquiryField(
|
||||
"partnershipType",
|
||||
legacyFields.partnershipType,
|
||||
"select",
|
||||
"full",
|
||||
),
|
||||
toInquiryField("message", legacyFields.message, "textarea", "full"),
|
||||
].filter((field) => field.label || field.placeholder || field.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function prepareInquiryPayload(payload) {
|
||||
const normalized = normalizeInquiryForm(payload);
|
||||
const fields = Array.isArray(normalized?.inquiryForm?.fields)
|
||||
? normalized.inquiryForm.fields
|
||||
: [];
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
inquiryForm: {
|
||||
...normalized.inquiryForm,
|
||||
fields: fields.map((field, index) => ({
|
||||
id:
|
||||
String(field.id || `field-${index + 1}`)
|
||||
.trim()
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "") || `field-${index + 1}`,
|
||||
label: field.label || "",
|
||||
placeholder: field.placeholder || "",
|
||||
type: field.type || "text",
|
||||
width: field.width || "full",
|
||||
required: Boolean(field.required),
|
||||
options: Array.isArray(field.options) ? field.options.filter(Boolean) : [],
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const controller = createPageContentController({
|
||||
model: PartnershipsPage,
|
||||
modelName: "PartnershipsPage",
|
||||
auditAction: AUDIT_ACTIONS.UPDATE_PARTNERSHIPS,
|
||||
editorConfig: pageContentConfig.partnerships,
|
||||
editorConfig: partnershipsConfig,
|
||||
normalizeForEditor: normalizeInquiryForm,
|
||||
normalizeForApi: normalizeInquiryForm,
|
||||
preparePayload: prepareInquiryPayload,
|
||||
});
|
||||
|
||||
controller.index = async function index(req, res) {
|
||||
try {
|
||||
return await createRenderSingletonPageView({
|
||||
model: PartnershipsPage,
|
||||
editorConfig: partnershipsConfig,
|
||||
view: "admin/partnerships/index",
|
||||
normalizeForEditor: normalizeInquiryForm,
|
||||
})(req, res);
|
||||
} catch (error) {
|
||||
console.error("partnerships index error:", error);
|
||||
req.flash("error_msg", "Error loading Partnerships Management");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = controller;
|
||||
|
||||
@@ -1,11 +1,156 @@
|
||||
const PoliciesPage = require("../models/policiesPage");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const pageContentConfig = require("../utils/pageContentConfig");
|
||||
const {
|
||||
baseConfig: policiesConfig,
|
||||
createPoliciesSectionEditorConfig,
|
||||
} = require("../utils/contentEditors/policiesConfig");
|
||||
const createPageContentController = require("./_createPageContentController");
|
||||
const createRenderSingletonPageView = require("./_renderSingletonPageView");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const jsonHelper = require("../utils/jsonHelper");
|
||||
|
||||
module.exports = createPageContentController({
|
||||
function formatLastUpdated(date = new Date()) {
|
||||
return `Last updated: ${new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date)}`;
|
||||
}
|
||||
|
||||
function withLastUpdated(payload) {
|
||||
return {
|
||||
...payload,
|
||||
hero: {
|
||||
...(payload.hero || {}),
|
||||
lastUpdated: formatLastUpdated(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseController = createPageContentController({
|
||||
model: PoliciesPage,
|
||||
modelName: "PoliciesPage",
|
||||
auditAction: AUDIT_ACTIONS.UPDATE_POLICIES,
|
||||
editorConfig: pageContentConfig.policies,
|
||||
editorConfig: policiesConfig,
|
||||
preparePayload: withLastUpdated,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
...baseController,
|
||||
async index(req, res) {
|
||||
try {
|
||||
return await createRenderSingletonPageView({
|
||||
model: PoliciesPage,
|
||||
editorConfig: policiesConfig,
|
||||
view: "admin/policies/index",
|
||||
})(req, res);
|
||||
} catch (error) {
|
||||
console.error("policies index error:", error);
|
||||
req.flash("error_msg", "Error loading Policies Management");
|
||||
return req.session.save(() => res.redirect("/admin/dashboard"));
|
||||
}
|
||||
},
|
||||
|
||||
async editSections(req, res) {
|
||||
try {
|
||||
const doc = await PoliciesPage.getSingle();
|
||||
const data = doc.toObject();
|
||||
const policy = (data.policies || []).find(
|
||||
(item) => item.id === req.params.policyId,
|
||||
);
|
||||
|
||||
if (!policy) {
|
||||
req.flash("error_msg", "Policy not found");
|
||||
return req.session.save(() => res.redirect("/admin/policies"));
|
||||
}
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const backendUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const editorConfig = createPoliciesSectionEditorConfig(
|
||||
policy,
|
||||
data.policies || [],
|
||||
);
|
||||
|
||||
return res.render("admin/policies/sections", {
|
||||
layout: "layouts/main",
|
||||
title: editorConfig.title,
|
||||
subtitle: editorConfig.subtitle,
|
||||
data: { sections: policy.sections || [] },
|
||||
editorConfig,
|
||||
activeTab: "sections",
|
||||
frontendUrl,
|
||||
backendUrl,
|
||||
previewUrl: `${frontendUrl}${editorConfig.previewPath}`,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("policies section index error:", error);
|
||||
req.flash("error_msg", "Error loading policy sections");
|
||||
return req.session.save(() => res.redirect("/admin/policies"));
|
||||
}
|
||||
},
|
||||
|
||||
async updateSections(req, res) {
|
||||
try {
|
||||
const payload =
|
||||
typeof req.body.pageJson === "string"
|
||||
? JSON.parse(req.body.pageJson)
|
||||
: req.body.pageJson || {};
|
||||
const doc = await PoliciesPage.getSingle();
|
||||
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
const policyIndex = (doc.policies || []).findIndex(
|
||||
(item) => item.id === req.params.policyId,
|
||||
);
|
||||
|
||||
if (policyIndex === -1) {
|
||||
req.flash("error_msg", "Policy not found");
|
||||
return req.session.save(() => res.redirect("/admin/policies"));
|
||||
}
|
||||
|
||||
doc.policies[policyIndex].sections = Array.isArray(payload.sections)
|
||||
? payload.sections
|
||||
: [];
|
||||
doc.hero = {
|
||||
...(doc.hero || {}),
|
||||
lastUpdated: formatLastUpdated(),
|
||||
};
|
||||
doc.markModified("policies");
|
||||
doc.markModified("hero");
|
||||
await doc.save();
|
||||
|
||||
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "PoliciesPage",
|
||||
documentId: doc._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_POLICIES,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
const finalData = await PoliciesPage.findOne()
|
||||
.select("-_id -__v -createdAt -updatedAt")
|
||||
.lean();
|
||||
jsonHelper.writeJsonFile(policiesConfig.dataFile, finalData);
|
||||
|
||||
req.flash("success_msg", "Policy sections updated successfully");
|
||||
return req.session.save(() =>
|
||||
res.redirect(`/admin/policies/${req.params.policyId}/section`),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("policies section update error:", error);
|
||||
req.flash("error_msg", `Error updating policy sections: ${error.message}`);
|
||||
return req.session.save(() =>
|
||||
res.redirect(`/admin/policies/${req.params.policyId}/section`),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,12 +16,18 @@
|
||||
]
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Excellence Assured",
|
||||
"badge": "Accreditation Save Check",
|
||||
"title": "ACCREDITATION AND RECOGNITION",
|
||||
"description": "At the London Academy of Management and Sciences (LAMS), we are committed to maintaining the highest academic standards and delivering quality education that aligns with international benchmarks. Our accreditations reflect our dedication to excellence, credibility, and continuous improvement in higher education and professional learning."
|
||||
},
|
||||
"grid": {
|
||||
"tabs": ["All", "Registry", "Certification", "Accreditation", "Quality Assurance"],
|
||||
"tabs": [
|
||||
"All",
|
||||
"Registry",
|
||||
"Certification",
|
||||
"Accreditation",
|
||||
"Quality Assurance"
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"id": "ukrlp",
|
||||
|
||||
+31
-14
@@ -5,11 +5,11 @@
|
||||
"description": "We believe high-quality education should be accessible to everyone. Explore our straightforward admissions process and flexible payment models designed to fit your life.",
|
||||
"primaryCta": {
|
||||
"label": "Start Application",
|
||||
"href": "#apply"
|
||||
"href": "http://localhost:3001/admin/admissions"
|
||||
},
|
||||
"secondaryCta": {
|
||||
"label": "View Tuition",
|
||||
"href": "#tuition-breakdown"
|
||||
"href": "http://localhost:3001/admin/admissions"
|
||||
},
|
||||
"image": "/uploads/admissions/hero-students.png",
|
||||
"imageAlt": "Diverse adult students studying online"
|
||||
@@ -20,19 +20,19 @@
|
||||
"description": "Our streamlined process gets you from application to enrolled in days, not months. No application fees, no standardized tests.",
|
||||
"steps": [
|
||||
{
|
||||
"number": "1",
|
||||
"title": "Submit Application",
|
||||
"description": "Fill out our online form in under 15 minutes. Basic personal and educational history required.",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"number": "01",
|
||||
"title": "Send Transcripts",
|
||||
"description": "Request official transcripts from previous institutions for credit evaluation.",
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
"number": "02",
|
||||
"title": "Submit Application",
|
||||
"description": "Fill out our online form in under 15 minutes. Basic personal and educational history required.",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"number": "03",
|
||||
"title": "Choose Payment Plan",
|
||||
"description": "Select between our monthly subscription or pay-per-course model.",
|
||||
"active": false
|
||||
@@ -72,19 +72,33 @@
|
||||
{
|
||||
"label": "Traditional University",
|
||||
"color": "#0F172A",
|
||||
"values": [25000, 50000, 75000, 100000]
|
||||
"values": [
|
||||
25000,
|
||||
50000,
|
||||
75000,
|
||||
100000
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "LAMS",
|
||||
"color": "#c49b27",
|
||||
"values": [3588, 7176, 10764, 14352]
|
||||
"values": [
|
||||
3588,
|
||||
7176,
|
||||
10764,
|
||||
14352
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"keyDates": {
|
||||
"id": "key-dates",
|
||||
"title": "Key Dates & Deadlines",
|
||||
"columns": ["Term", "Application Deadline", "Classes Start"],
|
||||
"columns": [
|
||||
"Term",
|
||||
"Application Deadline",
|
||||
"Classes Start"
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"term": "Fall Term 1",
|
||||
@@ -106,7 +120,10 @@
|
||||
"calculator": {
|
||||
"title": "Affordability Calculator",
|
||||
"description": "Estimate your monthly investment.",
|
||||
"modelOptions": ["Subscription", "Per Course"],
|
||||
"modelOptions": [
|
||||
"Subscription",
|
||||
"Per Course"
|
||||
],
|
||||
"paceLabel": "Target Pace",
|
||||
"minPaceLabel": "Relaxed",
|
||||
"maxPaceLabel": "Accelerated",
|
||||
|
||||
+7
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"highlight": {
|
||||
"icon": "fa-trophy",
|
||||
"text": "2025 Milestone Reached: A Rapidly Growing Global Community!",
|
||||
"text": "History Save Check",
|
||||
"linkLabel": "Read Full Story",
|
||||
"href": "#timeline-content"
|
||||
},
|
||||
@@ -14,7 +14,12 @@
|
||||
"yearLabel": "Decade / Year",
|
||||
"categoryLabel": "Category",
|
||||
"buttonLabel": "Apply Filters",
|
||||
"yearOptions": ["All Years", "2020 - Present", "2010 - 2019", "2005 - 2009"],
|
||||
"yearOptions": [
|
||||
"All Years",
|
||||
"2020 - Present",
|
||||
"2010 - 2019",
|
||||
"2005 - 2009"
|
||||
],
|
||||
"categoryOptions": [
|
||||
"All Categories",
|
||||
"Academic Programs",
|
||||
|
||||
+39
-14
@@ -2,13 +2,13 @@
|
||||
"hero": {
|
||||
"badge": "Global NetworkGlobal NetworkGlobal Netwo",
|
||||
"title": "Industry & Academic Partnerships.Industry & Academic Partnerships.Industry & Academic Part",
|
||||
"description": "Connecting our students with leading organizations for real-world experience, research opportunities, and career advancement.Connecting our students with leading organizations for real-world experience, research opportun",
|
||||
"description": "Global NetworkGlobal NetworkGlobal NetwoGlobal NetworkGlobal NetworkGlobal NetwoGlobal NetworkGlobal NetworkGlobal NetwoGlobal NetworkGlobal NetworkGlobal NetwoGlobal NetworkGlobal NetworkGlobal NetwoGlobal NetworkGlobal",
|
||||
"linkLabel": "Explore DirectoryExplore DirectoryExplor",
|
||||
"image": "/uploads/partnerships/kVI17_2B.webp",
|
||||
"imageAlt": "Modern university campus and corporate office buildingModern university campus and corporate office buildingModern unive"
|
||||
},
|
||||
"directory": {
|
||||
"heading": "Partner DirectoryPartner DirectoryPartner DirectoryPartner DirectoryPa",
|
||||
"heading": "Partner DirectoryPartner DirectoryPartne",
|
||||
"description": "Discover the organizations shaping the future of education with us.Discover the organizations shaping the future of education with us.Discover the organizations shaping the future ",
|
||||
"tabs": [
|
||||
"All PartnersAll PartnersAll Pa",
|
||||
@@ -89,27 +89,47 @@
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"heading": "Join the ecosystem.Join the ecosystem.Join the ecosystem.Join the ecosystem.Join",
|
||||
"heading": "Join the ecosystem",
|
||||
"description": "Partner with London Academy of Management and Sciences to build talent pipelines, collaborate on research, and shape the next generation of leaders.Partner with London Academy of Management and Sciences to build talent p",
|
||||
"buttonLabel": "Become a partnerBecome a partnerBecome a"
|
||||
},
|
||||
"inquiryForm": {
|
||||
"title": "Partnership InquiryPartnership InquiryPartnership InquiryPar",
|
||||
"fields": {
|
||||
"firstName": {
|
||||
"fields": [
|
||||
{
|
||||
"id": "firstName",
|
||||
"label": "Partnership InquiryPartnership",
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPa"
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPa",
|
||||
"type": "text",
|
||||
"width": "half",
|
||||
"required": true,
|
||||
"options": []
|
||||
},
|
||||
"lastName": {
|
||||
{
|
||||
"id": "lastName",
|
||||
"label": "Partnership InquiryPartnership",
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPa"
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPa",
|
||||
"type": "text",
|
||||
"width": "half",
|
||||
"required": true,
|
||||
"options": []
|
||||
},
|
||||
"organization": {
|
||||
{
|
||||
"id": "organization",
|
||||
"label": "Partnership InquiryPartnership InquiryPa",
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPartnership InquiryPar"
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPartnership InquiryPar",
|
||||
"type": "text",
|
||||
"width": "full",
|
||||
"required": true,
|
||||
"options": []
|
||||
},
|
||||
"partnershipType": {
|
||||
{
|
||||
"id": "partnershipType",
|
||||
"label": "Partnership InquiryPartnership InquiryPa",
|
||||
"placeholder": "",
|
||||
"type": "select",
|
||||
"width": "full",
|
||||
"required": true,
|
||||
"options": [
|
||||
"Partnership InquiryPartnership InquiryPartnership ",
|
||||
"Partnership InquiryPartnership InquiryPartnership ",
|
||||
@@ -117,11 +137,16 @@
|
||||
"Partnership InquiryPartnership InquiryPartnership "
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
{
|
||||
"id": "message",
|
||||
"label": "Partnership InquiryPartnership",
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPartnership InquiryPartnership InquiryPart"
|
||||
"placeholder": "Partnership InquiryPartnership InquiryPartnership InquiryPartnership InquiryPart",
|
||||
"type": "textarea",
|
||||
"width": "full",
|
||||
"required": true,
|
||||
"options": []
|
||||
}
|
||||
},
|
||||
],
|
||||
"submitLabel": "Partnership InquiryPartnership InquiryPa"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"titlePrefix": "Our Commitment to",
|
||||
"titleHighlight": "Transparency",
|
||||
"description": "Review our policies, terms of service, and commitments to privacy and accessibility. We believe in clear, straightforward communication with our academic community.",
|
||||
"lastUpdated": "Last updated: September 15, 2025"
|
||||
"lastUpdated": "Last updated: April 20, 2026"
|
||||
},
|
||||
"sidebar": {
|
||||
"heading": "Policies",
|
||||
|
||||
@@ -24,7 +24,14 @@
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
activeTabInput.value = this.dataset.tabKey;
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +51,16 @@
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
@@ -53,10 +70,18 @@
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey);
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey) {
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
@@ -67,7 +92,11 @@
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey);
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -77,24 +106,27 @@
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3";
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>Add ${escapeHtml(schema.itemLabel || "Item")}
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
@@ -103,47 +135,114 @@
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = `No ${schema.itemLabel || "items"} yet.`;
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "card-header bg-white d-flex justify-content-between align-items-center";
|
||||
itemHeader.className = "card-header bg-white d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<span class="fw-semibold">${escapeHtml(schema.itemLabel || "Item")} ${index + 1}</span>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
itemHeader.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
renderSection(tabKey);
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index);
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(schema.itemSchema, itemBody, parent[key], index, tabKey);
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey);
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
card.appendChild(itemCard);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
@@ -156,10 +255,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key);
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index) {
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
@@ -179,20 +278,25 @@
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea");
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
input.addEventListener("input", function () {
|
||||
targetArray[index] = holder.value;
|
||||
});
|
||||
input.addEventListener("change", function () {
|
||||
targetArray[index] = holder.value;
|
||||
});
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey) {
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
@@ -212,13 +316,14 @@
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section Type",
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
@@ -236,14 +341,20 @@
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey);
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key) {
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
parent[key] = parent[key] || "";
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -317,13 +428,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const input =
|
||||
schema.type === "select" ? document.createElement("select") : document.createElement("input");
|
||||
|
||||
input.className = "form-control";
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
(schema.options || []).forEach((option) => {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
@@ -338,31 +453,115 @@
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
} else {
|
||||
input.type =
|
||||
schema.type === "url" || schema.type === "number" || schema.type === "color"
|
||||
? schema.type
|
||||
: "text";
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
if (schema.type === "icon") {
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
}
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] =
|
||||
schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
@@ -492,7 +691,7 @@
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
@@ -503,7 +702,7 @@
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = "";
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
@@ -517,7 +716,7 @@
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image") return "col-12";
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
@@ -529,6 +728,14 @@
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
@@ -556,6 +763,99 @@
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,16 @@ router.post(
|
||||
);
|
||||
router.get("/policies", ensureAuthenticated, policiesController.index);
|
||||
router.post("/policies/update", ensureAuthenticated, policiesController.update);
|
||||
router.get(
|
||||
"/policies/:policyId/section",
|
||||
ensureAuthenticated,
|
||||
policiesController.editSections,
|
||||
);
|
||||
router.post(
|
||||
"/policies/:policyId/section/update",
|
||||
ensureAuthenticated,
|
||||
policiesController.updateSections,
|
||||
);
|
||||
|
||||
// Booking admin CRUD removed
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
const {
|
||||
text,
|
||||
textarea,
|
||||
image,
|
||||
icon,
|
||||
url,
|
||||
select,
|
||||
combobox,
|
||||
object,
|
||||
stringList,
|
||||
objectList,
|
||||
} = require("./sharedFields");
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "Active", label: "Active" },
|
||||
{ value: "Inactive", label: "Inactive" },
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
key: "accreditation",
|
||||
title: "Accreditation Management",
|
||||
subtitle: "Edit content displayed on the accreditation page",
|
||||
routeBase: "/admin/accreditation",
|
||||
apiPath: "/api/accreditation",
|
||||
previewPath: "/about/accreditation",
|
||||
dataFile: "accreditation",
|
||||
imageType: "accreditation",
|
||||
tabs: [
|
||||
{
|
||||
key: "trustBanner",
|
||||
label: "Trust Banner",
|
||||
icon: "fas fa-shield-check",
|
||||
schema: object("trustBanner", "Trust banner", [
|
||||
icon("icon", "Banner icon"),
|
||||
text("text", "Banner message", {
|
||||
maxLength: 120,
|
||||
helpText:
|
||||
"This is the slim credibility strip shown above the accreditation content.",
|
||||
}),
|
||||
objectList(
|
||||
"links",
|
||||
"Banner links",
|
||||
[
|
||||
text("label", "Link label", { maxLength: 40 }),
|
||||
url("href", "Link URL", { maxLength: 255 }),
|
||||
icon("icon", "Link icon"),
|
||||
],
|
||||
{
|
||||
itemLabel: "Link",
|
||||
sortable: true,
|
||||
emptyText: "No trust banner links yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Eyebrow label", { maxLength: 40 }),
|
||||
text("title", "Headline", { maxLength: 60 }),
|
||||
textarea("description", "Supporting text", { maxLength: 420, rows: 5 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "grid",
|
||||
label: "Accreditation Grid",
|
||||
icon: "fas fa-table-cells-large",
|
||||
schema: object("grid", "Accreditation grid", [
|
||||
stringList("tabs", "Category tabs", {
|
||||
itemLabel: "Tab",
|
||||
maxLength: 30,
|
||||
sortable: true,
|
||||
helpText:
|
||||
"The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.",
|
||||
}),
|
||||
objectList(
|
||||
"items",
|
||||
"Accreditation cards",
|
||||
[
|
||||
icon("icon", "Fallback icon"),
|
||||
image("image", "Card image", {
|
||||
imageHint: "Recommended 118x58 px minimum visible ratio",
|
||||
helpText: "Logo or badge used at the top of the card.",
|
||||
}),
|
||||
select("status", "Status", statusOptions),
|
||||
combobox("category", "Category", {
|
||||
maxLength: 30,
|
||||
optionsPath: "grid.tabs",
|
||||
}),
|
||||
text("title", "Card title", {
|
||||
maxLength: 17,
|
||||
helpText: "Frontend title space is capped at 17 characters.",
|
||||
}),
|
||||
textarea("description", "Card description", {
|
||||
maxLength: 300,
|
||||
rows: 5,
|
||||
helpText: "Frontend description preview is capped at 300 characters.",
|
||||
}),
|
||||
text("scopeLabel", "Scope label", { maxLength: 20 }),
|
||||
text("scope", "Scope text", { maxLength: 60 }),
|
||||
text("validUntilLabel", "Validity label", { maxLength: 24 }),
|
||||
text("validUntil", "Validity text", { maxLength: 40 }),
|
||||
text("buttonLabel", "Certificate button label", { maxLength: 30 }),
|
||||
url("certificateHref", "Certificate URL", { maxLength: 255 }),
|
||||
],
|
||||
{
|
||||
itemLabel: "Accreditation",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
itemSubtitleKey: "category",
|
||||
emptyText: "No accreditation cards yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
const {
|
||||
text,
|
||||
hidden,
|
||||
textarea,
|
||||
image,
|
||||
icon,
|
||||
checkbox,
|
||||
object,
|
||||
stringList,
|
||||
objectList,
|
||||
linkFields,
|
||||
} = require("./sharedFields");
|
||||
|
||||
module.exports = {
|
||||
key: "admissions",
|
||||
title: "Admissions Management",
|
||||
subtitle: "Edit content displayed on the admissions page",
|
||||
routeBase: "/admin/admissions",
|
||||
apiPath: "/api/admissions",
|
||||
previewPath: "/admissions",
|
||||
dataFile: "admissions",
|
||||
imageType: "admissions",
|
||||
tabs: [
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Eyebrow label", { maxLength: 40 }),
|
||||
text("title", "Headline", { maxLength: 80 }),
|
||||
textarea("description", "Supporting text", { maxLength: 240, rows: 4 }),
|
||||
object("primaryCta", "Primary button", linkFields("Primary button")),
|
||||
object("secondaryCta", "Secondary button", linkFields("Secondary button")),
|
||||
image("image", "Hero image", {
|
||||
imageHint: "Recommended 720x646 px",
|
||||
helpText: "Large image rendered in the right hero panel.",
|
||||
}),
|
||||
text("imageAlt", "Hero image alt text", { maxLength: 120 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "process",
|
||||
label: "Admissions Process",
|
||||
icon: "fas fa-list-ol",
|
||||
schema: object("process", "Admissions process", [
|
||||
text("id", "Section key", { maxLength: 40 }),
|
||||
text("title", "Section title", { maxLength: 60 }),
|
||||
textarea("description", "Section description", {
|
||||
maxLength: 180,
|
||||
rows: 3,
|
||||
}),
|
||||
objectList(
|
||||
"steps",
|
||||
"Steps",
|
||||
[
|
||||
hidden("number", { autoSequence: { padLength: 2 } }),
|
||||
text("title", "Step title", { maxLength: 50 }),
|
||||
textarea("description", "Step description", {
|
||||
maxLength: 180,
|
||||
rows: 3,
|
||||
}),
|
||||
checkbox("active", "Highlight this step"),
|
||||
],
|
||||
{
|
||||
itemLabel: "Step",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
itemSubtitleKey: "number",
|
||||
emptyText: "No process steps yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "eligibility",
|
||||
label: "Eligibility",
|
||||
icon: "fas fa-check-circle",
|
||||
schema: object("eligibility", "Eligibility", [
|
||||
text("id", "Section key", { maxLength: 40 }),
|
||||
text("title", "Section title", { maxLength: 60 }),
|
||||
objectList(
|
||||
"cards",
|
||||
"Eligibility cards",
|
||||
[
|
||||
text("title", "Card title", { maxLength: 50 }),
|
||||
icon("icon", "Card icon"),
|
||||
stringList("items", "Checklist items", {
|
||||
itemLabel: "Checklist item",
|
||||
maxLength: 120,
|
||||
sortable: true,
|
||||
}),
|
||||
],
|
||||
{
|
||||
itemLabel: "Card",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
emptyText: "No eligibility cards yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "tuition",
|
||||
label: "Tuition",
|
||||
icon: "fas fa-chart-column",
|
||||
schema: object("tuition", "Tuition", [
|
||||
text("id", "Section key", { maxLength: 40 }),
|
||||
text("title", "Section title", { maxLength: 60 }),
|
||||
text("chartTitle", "Chart title", { maxLength: 60 }),
|
||||
textarea("chartDescription", "Chart description", {
|
||||
maxLength: 140,
|
||||
rows: 3,
|
||||
}),
|
||||
objectList(
|
||||
"series",
|
||||
"Chart series",
|
||||
[
|
||||
text("label", "Series label", { maxLength: 40 }),
|
||||
{ key: "color", label: "Series color", type: "color" },
|
||||
stringList("values", "Data points", {
|
||||
itemLabel: "Point",
|
||||
fieldType: "number",
|
||||
sortable: true,
|
||||
}),
|
||||
],
|
||||
{
|
||||
itemLabel: "Series",
|
||||
sortable: true,
|
||||
itemTitleKey: "label",
|
||||
emptyText: "No chart series yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "keyDates",
|
||||
label: "Key Dates",
|
||||
icon: "fas fa-calendar-days",
|
||||
schema: object("keyDates", "Key dates", [
|
||||
text("id", "Section key", { maxLength: 40 }),
|
||||
text("title", "Section title", { maxLength: 60 }),
|
||||
stringList("columns", "Table columns", {
|
||||
itemLabel: "Column",
|
||||
maxLength: 40,
|
||||
sortable: true,
|
||||
}),
|
||||
objectList(
|
||||
"rows",
|
||||
"Table rows",
|
||||
[
|
||||
text("term", "Term", { maxLength: 40 }),
|
||||
text("applicationDeadline", "Application deadline", {
|
||||
maxLength: 40,
|
||||
}),
|
||||
text("classesStart", "Classes start", { maxLength: 40 }),
|
||||
],
|
||||
{
|
||||
itemLabel: "Row",
|
||||
sortable: true,
|
||||
itemTitleKey: "term",
|
||||
emptyText: "No key date rows yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "calculator",
|
||||
label: "Calculator",
|
||||
icon: "fas fa-calculator",
|
||||
schema: object("calculator", "Calculator", [
|
||||
text("title", "Card title", { maxLength: 60 }),
|
||||
textarea("description", "Card description", {
|
||||
maxLength: 120,
|
||||
rows: 3,
|
||||
}),
|
||||
stringList("modelOptions", "Model options", {
|
||||
itemLabel: "Option",
|
||||
maxLength: 30,
|
||||
sortable: true,
|
||||
}),
|
||||
text("paceLabel", "Pace label", { maxLength: 30 }),
|
||||
text("minPaceLabel", "Minimum pace label", { maxLength: 20 }),
|
||||
text("maxPaceLabel", "Maximum pace label", { maxLength: 20 }),
|
||||
text("resultLabel", "Result label", { maxLength: 40 }),
|
||||
text("monthlyAmount", "Monthly amount", { maxLength: 20 }),
|
||||
text("monthlySuffix", "Monthly suffix", { maxLength: 10 }),
|
||||
icon("noteIcon", "Note icon"),
|
||||
text("note", "Note text", { maxLength: 60 }),
|
||||
object("cta", "Button", linkFields("Button")),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "scholarships",
|
||||
label: "Scholarships",
|
||||
icon: "fas fa-award",
|
||||
schema: object("scholarships", "Scholarships", [
|
||||
text("title", "Card title", { maxLength: 50 }),
|
||||
icon("icon", "Card icon"),
|
||||
objectList(
|
||||
"items",
|
||||
"Scholarship items",
|
||||
[
|
||||
text("title", "Title", { maxLength: 50 }),
|
||||
text("amount", "Amount", { maxLength: 24 }),
|
||||
textarea("description", "Description", {
|
||||
maxLength: 160,
|
||||
rows: 3,
|
||||
}),
|
||||
],
|
||||
{
|
||||
itemLabel: "Scholarship item",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
itemSubtitleKey: "amount",
|
||||
emptyText: "No scholarship items yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
const {
|
||||
text,
|
||||
textarea,
|
||||
image,
|
||||
icon,
|
||||
checkbox,
|
||||
url,
|
||||
combobox,
|
||||
object,
|
||||
stringList,
|
||||
objectList,
|
||||
} = require("./sharedFields");
|
||||
|
||||
module.exports = {
|
||||
key: "history",
|
||||
title: "History Management",
|
||||
subtitle: "Edit content displayed on the history page",
|
||||
routeBase: "/admin/history",
|
||||
apiPath: "/api/history",
|
||||
previewPath: "/about/history",
|
||||
dataFile: "history",
|
||||
imageType: "history",
|
||||
tabs: [
|
||||
{
|
||||
key: "highlight",
|
||||
label: "Highlight Bar",
|
||||
icon: "fas fa-star",
|
||||
schema: object("highlight", "Highlight bar", [
|
||||
icon("icon", "Highlight icon"),
|
||||
text("text", "Message", { maxLength: 110 }),
|
||||
text("linkLabel", "Link label", { maxLength: 30 }),
|
||||
url("href", "Link URL", { maxLength: 255 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Eyebrow label", { maxLength: 40 }),
|
||||
text("title", "Headline", { maxLength: 90 }),
|
||||
textarea("description", "Supporting text", { maxLength: 220, rows: 4 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "filters",
|
||||
label: "Filter Controls",
|
||||
icon: "fas fa-filter",
|
||||
schema: object("filters", "Filter controls", [
|
||||
text("yearLabel", "Year filter label", { maxLength: 30 }),
|
||||
text("categoryLabel", "Category filter label", { maxLength: 30 }),
|
||||
text("buttonLabel", "Apply button label", { maxLength: 30 }),
|
||||
stringList("yearOptions", "Year options", {
|
||||
itemLabel: "Year option",
|
||||
maxLength: 30,
|
||||
sortable: true,
|
||||
helpText: "Reorder to control the dropdown order shown to users.",
|
||||
}),
|
||||
stringList("categoryOptions", "Category options", {
|
||||
itemLabel: "Category option",
|
||||
maxLength: 40,
|
||||
sortable: true,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "timeline",
|
||||
label: "Timeline",
|
||||
icon: "fas fa-clock-rotate-left",
|
||||
schema: object("timeline", "Timeline", [
|
||||
text("loadMoreLabel", "Load more button label", { maxLength: 40 }),
|
||||
objectList(
|
||||
"items",
|
||||
"Milestones",
|
||||
[
|
||||
text("id", "Milestone key", { maxLength: 60 }),
|
||||
text("year", "Year", { maxLength: 10 }),
|
||||
combobox("yearRange", "Year range", {
|
||||
maxLength: 30,
|
||||
optionsPath: "filters.yearOptions",
|
||||
}),
|
||||
combobox("category", "Category", {
|
||||
maxLength: 40,
|
||||
optionsPath: "filters.categoryOptions",
|
||||
}),
|
||||
text("categoryLabel", "Category badge label", { maxLength: 30 }),
|
||||
text("title", "Milestone title", { maxLength: 90 }),
|
||||
textarea("description", "Description", {
|
||||
maxLength: 260,
|
||||
rows: 4,
|
||||
}),
|
||||
image("image", "Milestone image", {
|
||||
imageHint: "Recommended 436x190 px",
|
||||
helpText: "Wide image used inside the milestone card.",
|
||||
}),
|
||||
text("imageAlt", "Image alt text", { maxLength: 120 }),
|
||||
objectList(
|
||||
"stats",
|
||||
"Stats",
|
||||
[
|
||||
text("value", "Value", { maxLength: 24 }),
|
||||
text("label", "Label", { maxLength: 50 }),
|
||||
],
|
||||
{
|
||||
itemLabel: "Stat",
|
||||
sortable: true,
|
||||
emptyText: "No stats yet.",
|
||||
},
|
||||
),
|
||||
checkbox("featured", "Featured milestone"),
|
||||
],
|
||||
{
|
||||
itemLabel: "Milestone",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
itemSubtitleKey: "year",
|
||||
emptyText: "No milestones yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
const {
|
||||
text,
|
||||
textarea,
|
||||
image,
|
||||
checkbox,
|
||||
select,
|
||||
combobox,
|
||||
object,
|
||||
stringList,
|
||||
objectList,
|
||||
} = require("./sharedFields");
|
||||
|
||||
const inquiryFieldOptions = [
|
||||
{ value: "text", label: "Single line text" },
|
||||
{ value: "textarea", label: "Paragraph" },
|
||||
{ value: "select", label: "Dropdown" },
|
||||
];
|
||||
|
||||
const widthOptions = [
|
||||
{ value: "half", label: "Half width" },
|
||||
{ value: "full", label: "Full width" },
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
key: "partnerships",
|
||||
title: "Partnerships Management",
|
||||
subtitle: "Edit content displayed on the partnerships page",
|
||||
routeBase: "/admin/partnerships",
|
||||
apiPath: "/api/partnerships",
|
||||
previewPath: "/about/partnerships",
|
||||
dataFile: "partnerships",
|
||||
imageType: "partnerships",
|
||||
tabs: [
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Eyebrow label", { maxLength: 40 }),
|
||||
text("title", "Headline", { maxLength: 90 }),
|
||||
textarea("description", "Supporting text", { maxLength: 220, rows: 4 }),
|
||||
text("linkLabel", "Scroll link label", { maxLength: 40 }),
|
||||
image("image", "Hero image", {
|
||||
imageHint: "Recommended 720x630 px",
|
||||
helpText: "Upload the image shown in the right hero panel.",
|
||||
}),
|
||||
text("imageAlt", "Hero image alt text", { maxLength: 120 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "directory",
|
||||
label: "Partner Directory",
|
||||
icon: "fas fa-handshake",
|
||||
schema: object("directory", "Partner directory", [
|
||||
text("heading", "Section heading", { maxLength: 70 }),
|
||||
textarea("description", "Section description", {
|
||||
maxLength: 180,
|
||||
rows: 3,
|
||||
}),
|
||||
stringList("tabs", "Category tabs", {
|
||||
itemLabel: "Category tab",
|
||||
maxLength: 30,
|
||||
placeholder: "Industry",
|
||||
sortable: true,
|
||||
helpText:
|
||||
"The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.",
|
||||
}),
|
||||
text("loadMoreLabel", "Load more button label", { maxLength: 40 }),
|
||||
objectList(
|
||||
"partners",
|
||||
"Partners",
|
||||
[
|
||||
text("id", "Partner key", {
|
||||
maxLength: 50,
|
||||
helpText:
|
||||
"Use a short unique key. It keeps each card state separate on the frontend.",
|
||||
}),
|
||||
text("name", "Partner name", { maxLength: 90 }),
|
||||
combobox("category", "Category", {
|
||||
maxLength: 30,
|
||||
optionsPath: "directory.tabs",
|
||||
}),
|
||||
textarea("summary", "Card summary", {
|
||||
maxLength: 130,
|
||||
rows: 3,
|
||||
helpText: "The card preview is capped at 130 characters.",
|
||||
}),
|
||||
image("logo", "Partner logo", {
|
||||
imageHint: "Recommended 105x80 px minimum visible ratio",
|
||||
helpText: "Use a clean logo with transparent or simple background.",
|
||||
}),
|
||||
text("logoAlt", "Logo alt text", { maxLength: 120 }),
|
||||
textarea("about", "About text", { maxLength: 600, rows: 5 }),
|
||||
text("collabType", "Collaboration type", { maxLength: 40 }),
|
||||
textarea("benefits", "Benefits", { maxLength: 240, rows: 4 }),
|
||||
],
|
||||
{
|
||||
itemLabel: "Partner",
|
||||
sortable: true,
|
||||
itemTitleKey: "name",
|
||||
itemSubtitleKey: "category",
|
||||
emptyText: "No partners yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "cta",
|
||||
label: "Call To Action",
|
||||
icon: "fas fa-bullhorn",
|
||||
schema: object("cta", "Call to action", [
|
||||
text("heading", "Headline", { maxLength: 80 }),
|
||||
textarea("description", "Supporting text", {
|
||||
maxLength: 220,
|
||||
rows: 4,
|
||||
}),
|
||||
text("buttonLabel", "Button label", { maxLength: 40 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "inquiryForm",
|
||||
label: "Inquiry Form",
|
||||
icon: "fas fa-envelope",
|
||||
schema: object("inquiryForm", "Inquiry form", [
|
||||
text("title", "Modal title", { maxLength: 60 }),
|
||||
objectList(
|
||||
"fields",
|
||||
"Form fields",
|
||||
[
|
||||
text("id", "Field key", {
|
||||
maxLength: 40,
|
||||
helpText:
|
||||
"Use a short unique key such as firstName or organization.",
|
||||
}),
|
||||
text("label", "Field label", { maxLength: 40 }),
|
||||
text("placeholder", "Placeholder text", { maxLength: 80 }),
|
||||
select("type", "Field type", inquiryFieldOptions),
|
||||
select("width", "Field width", widthOptions),
|
||||
stringList("options", "Dropdown options", {
|
||||
itemLabel: "Option",
|
||||
maxLength: 50,
|
||||
sortable: true,
|
||||
helpText: "Only used when the field type is Dropdown.",
|
||||
visibleWhen: { path: "type", equals: "select" },
|
||||
}),
|
||||
checkbox("required", "Required field"),
|
||||
],
|
||||
{
|
||||
itemLabel: "Field",
|
||||
sortable: true,
|
||||
itemTitleKey: "label",
|
||||
itemSubtitleKey: "type",
|
||||
emptyText: "No inquiry fields yet.",
|
||||
},
|
||||
),
|
||||
text("submitLabel", "Submit button label", { maxLength: 40 }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
const {
|
||||
text,
|
||||
textarea,
|
||||
icon,
|
||||
url,
|
||||
combobox,
|
||||
object,
|
||||
objectList,
|
||||
stringList,
|
||||
variantList,
|
||||
} = require("./sharedFields");
|
||||
|
||||
const baseConfig = {
|
||||
key: "policies",
|
||||
title: "Policies Management",
|
||||
subtitle: "Edit content displayed on the policies page",
|
||||
routeBase: "/admin/policies",
|
||||
apiPath: "/api/policies",
|
||||
previewPath: "/policies",
|
||||
dataFile: "policies",
|
||||
imageType: "policies",
|
||||
tabs: [
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-scale-balanced",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Eyebrow label", { maxLength: 40 }),
|
||||
icon("icon", "Hero icon", {
|
||||
helpText: "Policies uses icon-only controls and does not require image upload.",
|
||||
}),
|
||||
text("titlePrefix", "Headline prefix", { maxLength: 50 }),
|
||||
text("titleHighlight", "Headline highlight", { maxLength: 40 }),
|
||||
textarea("description", "Supporting text", {
|
||||
maxLength: 220,
|
||||
rows: 4,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "sidebar",
|
||||
label: "Sidebar",
|
||||
icon: "fas fa-bars",
|
||||
schema: object("sidebar", "Sidebar", [
|
||||
text("heading", "Sidebar heading", { maxLength: 30 }),
|
||||
text("helperText", "Helper text", { maxLength: 60 }),
|
||||
text("contactLabel", "Contact link label", { maxLength: 40 }),
|
||||
url("contactHref", "Contact URL", { maxLength: 255 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "policies",
|
||||
label: "Policies",
|
||||
icon: "fas fa-file-lines",
|
||||
schema: objectList(
|
||||
"policies",
|
||||
"Policies",
|
||||
[
|
||||
text("id", "Policy key", { maxLength: 40 }),
|
||||
text("navLabel", "Sidebar label", { maxLength: 40 }),
|
||||
text("title", "Policy title", { maxLength: 70 }),
|
||||
text("effectiveDate", "Effective date", { maxLength: 50 }),
|
||||
textarea("intro", "Intro text", { maxLength: 260, rows: 4 }),
|
||||
],
|
||||
{
|
||||
itemLabel: "Policy",
|
||||
sortable: true,
|
||||
itemTitleKey: "title",
|
||||
itemSubtitleKey: "effectiveDate",
|
||||
emptyText: "No policies yet.",
|
||||
itemActions: [
|
||||
{
|
||||
label: "Edit Sections",
|
||||
icon: "fas fa-pen-to-square",
|
||||
hrefTemplate: "/admin/policies/{id}/section",
|
||||
className: "btn btn-outline-primary btn-sm",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createPoliciesSectionEditorConfig(policy, allPolicies = []) {
|
||||
const policyOptions = allPolicies
|
||||
.filter((item) => item.id && item.id !== policy.id)
|
||||
.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.title || item.navLabel || item.id,
|
||||
}));
|
||||
|
||||
return {
|
||||
key: "policySections",
|
||||
title: `Policy Sections: ${policy.title || policy.id}`,
|
||||
subtitle: "Manage section content and paragraph order for this policy",
|
||||
routeBase: `/admin/policies/${policy.id}/section`,
|
||||
previewPath: "/policies",
|
||||
imageType: "policies",
|
||||
tabs: [
|
||||
{
|
||||
key: "sections",
|
||||
label: "Sections",
|
||||
icon: "fas fa-file-lines",
|
||||
schema: variantList(
|
||||
"sections",
|
||||
"Sections",
|
||||
{
|
||||
text: {
|
||||
label: "Text section",
|
||||
schema: object("section", "Text section", [
|
||||
text("heading", "Section heading", { maxLength: 60 }),
|
||||
objectList(
|
||||
"paragraphs",
|
||||
"Paragraphs",
|
||||
[
|
||||
textarea("text", "Paragraph text", {
|
||||
maxLength: 500,
|
||||
rows: 4,
|
||||
}),
|
||||
objectList(
|
||||
"links",
|
||||
"Inline links",
|
||||
[
|
||||
text("label", "Link label", { maxLength: 50 }),
|
||||
url("href", "Link URL", { maxLength: 255 }),
|
||||
combobox("tabId", "Switch to policy", {
|
||||
maxLength: 40,
|
||||
options: policyOptions,
|
||||
helpText:
|
||||
"Optional. Choose another policy to switch tabs on the frontend.",
|
||||
}),
|
||||
],
|
||||
{
|
||||
itemLabel: "Link",
|
||||
sortable: true,
|
||||
emptyText: "No inline links yet.",
|
||||
},
|
||||
),
|
||||
],
|
||||
{
|
||||
itemLabel: "Paragraph",
|
||||
sortable: true,
|
||||
emptyText: "No paragraphs yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
list: {
|
||||
label: "List section",
|
||||
schema: object("section", "List section", [
|
||||
text("heading", "Section heading", { maxLength: 60 }),
|
||||
textarea("intro", "Intro text", { maxLength: 220, rows: 3 }),
|
||||
stringList("items", "List items", {
|
||||
itemLabel: "List item",
|
||||
maxLength: 180,
|
||||
fieldType: "textarea",
|
||||
sortable: true,
|
||||
emptyText: "No list items yet.",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
cards: {
|
||||
label: "Card section",
|
||||
schema: object("section", "Card section", [
|
||||
objectList(
|
||||
"cards",
|
||||
"Cards",
|
||||
[
|
||||
icon("icon", "Card icon"),
|
||||
text("title", "Card title", { maxLength: 50 }),
|
||||
textarea("description", "Card description", {
|
||||
maxLength: 220,
|
||||
rows: 4,
|
||||
}),
|
||||
object("link", "Card link", [
|
||||
text("label", "Link label", { maxLength: 50 }),
|
||||
url("href", "Link URL", { maxLength: 255 }),
|
||||
combobox("tabId", "Switch to policy", {
|
||||
maxLength: 40,
|
||||
options: policyOptions,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
{
|
||||
itemLabel: "Card",
|
||||
sortable: true,
|
||||
emptyText: "No cards yet.",
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
},
|
||||
{
|
||||
itemLabel: "Section",
|
||||
sortable: true,
|
||||
emptyText: "No sections yet.",
|
||||
},
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
baseConfig,
|
||||
createPoliciesSectionEditorConfig,
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
const ICON_OPTIONS = [
|
||||
"fa-scale-balanced",
|
||||
"fa-shield-check",
|
||||
"fa-magnifying-glass",
|
||||
"fa-graduation-cap",
|
||||
"fa-check-double",
|
||||
"fa-building-columns",
|
||||
"fa-award",
|
||||
"fa-trophy",
|
||||
"fa-arrow-down",
|
||||
"fa-arrow-right",
|
||||
"fa-check-circle",
|
||||
"fa-exchange-alt",
|
||||
"fa-bolt",
|
||||
"fa-book-open",
|
||||
"fa-credit-card",
|
||||
"fa-calendar-days",
|
||||
"fa-envelope",
|
||||
"fa-file-lines",
|
||||
"fa-globe",
|
||||
"fa-briefcase",
|
||||
"fa-handshake",
|
||||
"fa-circle-info",
|
||||
"fa-star",
|
||||
"fa-list-check",
|
||||
];
|
||||
|
||||
const text = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "text",
|
||||
...options,
|
||||
});
|
||||
|
||||
const hidden = (key, options = {}) => ({
|
||||
key,
|
||||
type: "hidden",
|
||||
...options,
|
||||
});
|
||||
|
||||
const textarea = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "textarea",
|
||||
rows: options.rows || 4,
|
||||
...options,
|
||||
});
|
||||
|
||||
const image = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "image",
|
||||
...options,
|
||||
});
|
||||
|
||||
const icon = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "icon",
|
||||
options: options.options || ICON_OPTIONS,
|
||||
...options,
|
||||
});
|
||||
|
||||
const checkbox = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "checkbox",
|
||||
...options,
|
||||
});
|
||||
|
||||
const url = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "url",
|
||||
...options,
|
||||
});
|
||||
|
||||
const select = (key, label, options = [], extra = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "select",
|
||||
options,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const combobox = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "combobox",
|
||||
...options,
|
||||
});
|
||||
|
||||
const object = (key, label, fields, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "object",
|
||||
fields,
|
||||
...options,
|
||||
});
|
||||
|
||||
const stringList = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "array",
|
||||
itemLabel: options.itemLabel || "Item",
|
||||
sortable: options.sortable,
|
||||
addLabel: options.addLabel,
|
||||
helpText: options.helpText,
|
||||
emptyText: options.emptyText,
|
||||
itemSchema: {
|
||||
type: "primitive",
|
||||
fieldType: options.fieldType || "text",
|
||||
label: options.itemLabel || "Item",
|
||||
maxLength: options.maxLength,
|
||||
placeholder: options.placeholder,
|
||||
helpText: options.itemHelpText,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const objectList = (key, label, fields, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "array",
|
||||
itemLabel: options.itemLabel || "Item",
|
||||
sortable: options.sortable,
|
||||
addLabel: options.addLabel,
|
||||
helpText: options.helpText,
|
||||
emptyText: options.emptyText,
|
||||
itemTitleKey: options.itemTitleKey,
|
||||
itemSubtitleKey: options.itemSubtitleKey,
|
||||
itemActions: options.itemActions,
|
||||
itemSchema: {
|
||||
type: "object",
|
||||
fields,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const variantList = (key, label, variants, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "array",
|
||||
itemLabel: options.itemLabel || "Item",
|
||||
sortable: options.sortable,
|
||||
addLabel: options.addLabel,
|
||||
helpText: options.helpText,
|
||||
emptyText: options.emptyText,
|
||||
itemSchema: {
|
||||
type: "variant",
|
||||
discriminator: "type",
|
||||
options: Object.keys(variants).map((value) => ({
|
||||
value,
|
||||
label: variants[value].label,
|
||||
})),
|
||||
variants,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const linkFields = (prefix = "Link") => [
|
||||
text("label", `${prefix} label`, { maxLength: 60 }),
|
||||
url("href", `${prefix} URL`, { maxLength: 255 }),
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
ICON_OPTIONS,
|
||||
text,
|
||||
hidden,
|
||||
textarea,
|
||||
image,
|
||||
icon,
|
||||
checkbox,
|
||||
url,
|
||||
select,
|
||||
combobox,
|
||||
object,
|
||||
stringList,
|
||||
objectList,
|
||||
variantList,
|
||||
linkFields,
|
||||
};
|
||||
@@ -1,703 +0,0 @@
|
||||
const ICON_OPTIONS = [
|
||||
"fa-scale-balanced",
|
||||
"fa-shield-check",
|
||||
"fa-magnifying-glass",
|
||||
"fa-graduation-cap",
|
||||
"fa-check-double",
|
||||
"fa-building-columns",
|
||||
"fa-award",
|
||||
"fa-trophy",
|
||||
"fa-arrow-down",
|
||||
"fa-arrow-right",
|
||||
"fa-check-circle",
|
||||
"fa-exchange-alt",
|
||||
"fa-bolt",
|
||||
"fa-book-open",
|
||||
"fa-credit-card",
|
||||
];
|
||||
|
||||
const text = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "text",
|
||||
...options,
|
||||
});
|
||||
|
||||
const textarea = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "textarea",
|
||||
rows: options.rows || 4,
|
||||
...options,
|
||||
});
|
||||
|
||||
const image = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "image",
|
||||
...options,
|
||||
});
|
||||
|
||||
const icon = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "icon",
|
||||
options: options.options || ICON_OPTIONS,
|
||||
...options,
|
||||
});
|
||||
|
||||
const checkbox = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "checkbox",
|
||||
...options,
|
||||
});
|
||||
|
||||
const url = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "url",
|
||||
...options,
|
||||
});
|
||||
|
||||
const object = (key, label, fields, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "object",
|
||||
fields,
|
||||
...options,
|
||||
});
|
||||
|
||||
const stringList = (key, label, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "array",
|
||||
itemLabel: options.itemLabel || "Item",
|
||||
itemSchema: {
|
||||
type: "primitive",
|
||||
fieldType: options.fieldType || "text",
|
||||
label: options.itemLabel || "Item",
|
||||
maxLength: options.maxLength,
|
||||
placeholder: options.placeholder,
|
||||
helpText: options.itemHelpText,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const objectList = (key, label, fields, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "array",
|
||||
itemLabel: options.itemLabel || "Item",
|
||||
itemSchema: {
|
||||
type: "object",
|
||||
fields,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const variantList = (key, label, variants, options = {}) => ({
|
||||
key,
|
||||
label,
|
||||
type: "array",
|
||||
itemLabel: options.itemLabel || "Item",
|
||||
itemSchema: {
|
||||
type: "variant",
|
||||
discriminator: "type",
|
||||
options: Object.keys(variants).map((value) => ({
|
||||
value,
|
||||
label: variants[value].label,
|
||||
})),
|
||||
variants,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const linkFields = (prefix = "Link") => [
|
||||
text("label", `${prefix} Label`, { maxLength: 60 }),
|
||||
url("href", `${prefix} URL`, { maxLength: 255 }),
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
partnerships: {
|
||||
key: "partnerships",
|
||||
title: "Partnerships Management",
|
||||
subtitle: "Edit content displayed on the partnerships page",
|
||||
routeBase: "/admin/partnerships",
|
||||
apiPath: "/api/partnerships",
|
||||
previewPath: "/about/partnerships",
|
||||
dataFile: "partnerships",
|
||||
imageType: "partnerships",
|
||||
tabs: [
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Badge", { maxLength: 40 }),
|
||||
text("title", "Title", { maxLength: 90 }),
|
||||
textarea("description", "Description", { maxLength: 220, rows: 4 }),
|
||||
text("linkLabel", "Link Label", { maxLength: 40 }),
|
||||
image("image", "Hero Image", {
|
||||
imageHint: "Recommended 720x630 px",
|
||||
helpText: "Upload a landscape hero image for the right panel.",
|
||||
}),
|
||||
text("imageAlt", "Hero Image Alt Text", { maxLength: 120 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "directory",
|
||||
label: "Directory",
|
||||
icon: "fas fa-handshake",
|
||||
schema: object("directory", "Directory", [
|
||||
text("heading", "Heading", { maxLength: 70 }),
|
||||
textarea("description", "Description", { maxLength: 180, rows: 3 }),
|
||||
stringList("tabs", "Tabs", {
|
||||
itemLabel: "Tab",
|
||||
maxLength: 30,
|
||||
placeholder: "Industry",
|
||||
}),
|
||||
text("loadMoreLabel", "Load More Label", { maxLength: 40 }),
|
||||
objectList(
|
||||
"partners",
|
||||
"Partners",
|
||||
[
|
||||
text("id", "Partner ID", {
|
||||
maxLength: 50,
|
||||
helpText: "Stable ID used by the frontend filtering state.",
|
||||
}),
|
||||
text("name", "Name", { maxLength: 90 }),
|
||||
text("category", "Category", { maxLength: 30 }),
|
||||
textarea("summary", "Summary", {
|
||||
maxLength: 130,
|
||||
rows: 3,
|
||||
helpText:
|
||||
"The card preview uses a 130 character cap in the frontend.",
|
||||
}),
|
||||
image("logo", "Logo", {
|
||||
imageHint: "Recommended 105x80 px minimum visible ratio",
|
||||
helpText: "Use a clean logo with transparent or simple background.",
|
||||
}),
|
||||
text("logoAlt", "Logo Alt Text", { maxLength: 120 }),
|
||||
textarea("about", "About", { maxLength: 600, rows: 5 }),
|
||||
text("collabType", "Collaboration Type", { maxLength: 40 }),
|
||||
textarea("benefits", "Benefits", { maxLength: 240, rows: 4 }),
|
||||
],
|
||||
{ itemLabel: "Partner" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "cta",
|
||||
label: "CTA",
|
||||
icon: "fas fa-bullhorn",
|
||||
schema: object("cta", "CTA", [
|
||||
text("heading", "Heading", { maxLength: 80 }),
|
||||
textarea("description", "Description", { maxLength: 220, rows: 4 }),
|
||||
text("buttonLabel", "Button Label", { maxLength: 40 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "inquiryForm",
|
||||
label: "Inquiry Form",
|
||||
icon: "fas fa-envelope",
|
||||
schema: object("inquiryForm", "Inquiry Form", [
|
||||
text("title", "Title", { maxLength: 60 }),
|
||||
object("fields", "Fields", [
|
||||
object("firstName", "First Name Field", [
|
||||
text("label", "Label", { maxLength: 30 }),
|
||||
text("placeholder", "Placeholder", { maxLength: 40 }),
|
||||
]),
|
||||
object("lastName", "Last Name Field", [
|
||||
text("label", "Label", { maxLength: 30 }),
|
||||
text("placeholder", "Placeholder", { maxLength: 40 }),
|
||||
]),
|
||||
object("organization", "Organization Field", [
|
||||
text("label", "Label", { maxLength: 40 }),
|
||||
text("placeholder", "Placeholder", { maxLength: 60 }),
|
||||
]),
|
||||
object("partnershipType", "Partnership Type Field", [
|
||||
text("label", "Label", { maxLength: 40 }),
|
||||
stringList("options", "Options", {
|
||||
itemLabel: "Option",
|
||||
maxLength: 50,
|
||||
}),
|
||||
]),
|
||||
object("message", "Message Field", [
|
||||
text("label", "Label", { maxLength: 30 }),
|
||||
text("placeholder", "Placeholder", { maxLength: 80 }),
|
||||
]),
|
||||
]),
|
||||
text("submitLabel", "Submit Label", { maxLength: 40 }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
history: {
|
||||
key: "history",
|
||||
title: "History Management",
|
||||
subtitle: "Edit content displayed on the history page",
|
||||
routeBase: "/admin/history",
|
||||
apiPath: "/api/history",
|
||||
previewPath: "/about/history",
|
||||
dataFile: "history",
|
||||
imageType: "history",
|
||||
tabs: [
|
||||
{
|
||||
key: "highlight",
|
||||
label: "Highlight",
|
||||
icon: "fas fa-star",
|
||||
schema: object("highlight", "Highlight Banner", [
|
||||
icon("icon", "Icon"),
|
||||
text("text", "Text", { maxLength: 110 }),
|
||||
text("linkLabel", "Link Label", { maxLength: 30 }),
|
||||
url("href", "Link URL", { maxLength: 255 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Badge", { maxLength: 40 }),
|
||||
text("title", "Title", { maxLength: 90 }),
|
||||
textarea("description", "Description", { maxLength: 220, rows: 4 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "filters",
|
||||
label: "Filters",
|
||||
icon: "fas fa-filter",
|
||||
schema: object("filters", "Filters", [
|
||||
text("yearLabel", "Year Label", { maxLength: 30 }),
|
||||
text("categoryLabel", "Category Label", { maxLength: 30 }),
|
||||
text("buttonLabel", "Button Label", { maxLength: 30 }),
|
||||
stringList("yearOptions", "Year Options", {
|
||||
itemLabel: "Year Option",
|
||||
maxLength: 30,
|
||||
}),
|
||||
stringList("categoryOptions", "Category Options", {
|
||||
itemLabel: "Category Option",
|
||||
maxLength: 40,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "timeline",
|
||||
label: "Timeline",
|
||||
icon: "fas fa-clock-rotate-left",
|
||||
schema: object("timeline", "Timeline", [
|
||||
text("loadMoreLabel", "Load More Label", { maxLength: 40 }),
|
||||
objectList(
|
||||
"items",
|
||||
"Milestones",
|
||||
[
|
||||
text("id", "Milestone ID", { maxLength: 60 }),
|
||||
text("year", "Year", { maxLength: 10 }),
|
||||
text("yearRange", "Year Range", { maxLength: 30 }),
|
||||
text("category", "Category", { maxLength: 40 }),
|
||||
text("categoryLabel", "Category Label", { maxLength: 30 }),
|
||||
text("title", "Title", { maxLength: 90 }),
|
||||
textarea("description", "Description", {
|
||||
maxLength: 260,
|
||||
rows: 4,
|
||||
}),
|
||||
image("image", "Milestone Image", {
|
||||
imageHint: "Recommended 436x190 px",
|
||||
helpText: "Wide image used inside the milestone card.",
|
||||
}),
|
||||
text("imageAlt", "Image Alt Text", { maxLength: 120 }),
|
||||
objectList(
|
||||
"stats",
|
||||
"Stats",
|
||||
[
|
||||
text("value", "Value", { maxLength: 24 }),
|
||||
text("label", "Label", { maxLength: 50 }),
|
||||
],
|
||||
{ itemLabel: "Stat" },
|
||||
),
|
||||
checkbox("featured", "Featured"),
|
||||
],
|
||||
{ itemLabel: "Milestone" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
accreditation: {
|
||||
key: "accreditation",
|
||||
title: "Accreditation Management",
|
||||
subtitle: "Edit content displayed on the accreditation page",
|
||||
routeBase: "/admin/accreditation",
|
||||
apiPath: "/api/accreditation",
|
||||
previewPath: "/about/accreditation",
|
||||
dataFile: "accreditation",
|
||||
imageType: "accreditation",
|
||||
tabs: [
|
||||
{
|
||||
key: "trustBanner",
|
||||
label: "Trust Banner",
|
||||
icon: "fas fa-shield-check",
|
||||
schema: object("trustBanner", "Trust Banner", [
|
||||
icon("icon", "Icon"),
|
||||
text("text", "Text", { maxLength: 120 }),
|
||||
objectList(
|
||||
"links",
|
||||
"Links",
|
||||
[
|
||||
text("label", "Label", { maxLength: 40 }),
|
||||
url("href", "URL", { maxLength: 255 }),
|
||||
icon("icon", "Icon"),
|
||||
],
|
||||
{ itemLabel: "Link" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Badge", { maxLength: 40 }),
|
||||
text("title", "Title", { maxLength: 60 }),
|
||||
textarea("description", "Description", { maxLength: 420, rows: 5 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "grid",
|
||||
label: "Grid",
|
||||
icon: "fas fa-table-cells-large",
|
||||
schema: object("grid", "Grid", [
|
||||
stringList("tabs", "Tabs", {
|
||||
itemLabel: "Tab",
|
||||
maxLength: 30,
|
||||
}),
|
||||
objectList(
|
||||
"items",
|
||||
"Items",
|
||||
[
|
||||
text("id", "Item ID", { maxLength: 50 }),
|
||||
icon("icon", "Icon"),
|
||||
image("image", "Image", {
|
||||
imageHint: "Recommended 118x58 px minimum visible ratio",
|
||||
helpText: "Logo or badge used at the top of the card.",
|
||||
}),
|
||||
text("status", "Status", { maxLength: 20 }),
|
||||
text("category", "Category", { maxLength: 30 }),
|
||||
text("title", "Title", {
|
||||
maxLength: 17,
|
||||
helpText: "Frontend title space is capped at 17 characters.",
|
||||
}),
|
||||
textarea("description", "Description", {
|
||||
maxLength: 300,
|
||||
rows: 5,
|
||||
helpText:
|
||||
"Frontend description preview is capped at 300 characters.",
|
||||
}),
|
||||
text("scopeLabel", "Scope Label", { maxLength: 20 }),
|
||||
text("scope", "Scope", { maxLength: 60 }),
|
||||
text("validUntilLabel", "Valid Until Label", { maxLength: 24 }),
|
||||
text("validUntil", "Valid Until", { maxLength: 40 }),
|
||||
text("buttonLabel", "Button Label", { maxLength: 30 }),
|
||||
url("certificateHref", "Certificate URL", { maxLength: 255 }),
|
||||
],
|
||||
{ itemLabel: "Accreditation Item" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
admissions: {
|
||||
key: "admissions",
|
||||
title: "Admissions Management",
|
||||
subtitle: "Edit content displayed on the admissions page",
|
||||
routeBase: "/admin/admissions",
|
||||
apiPath: "/api/admissions",
|
||||
previewPath: "/admissions",
|
||||
dataFile: "admissions",
|
||||
imageType: "admissions",
|
||||
tabs: [
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-image",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Badge", { maxLength: 40 }),
|
||||
text("title", "Title", { maxLength: 80 }),
|
||||
textarea("description", "Description", { maxLength: 240, rows: 4 }),
|
||||
object("primaryCta", "Primary CTA", linkFields("Primary CTA")),
|
||||
object("secondaryCta", "Secondary CTA", linkFields("Secondary CTA")),
|
||||
image("image", "Hero Image", {
|
||||
imageHint: "Recommended 720x646 px",
|
||||
helpText: "Large image rendered in the right hero panel.",
|
||||
}),
|
||||
text("imageAlt", "Hero Image Alt Text", { maxLength: 120 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "process",
|
||||
label: "Process",
|
||||
icon: "fas fa-list-ol",
|
||||
schema: object("process", "Process", [
|
||||
text("id", "Section ID", { maxLength: 40 }),
|
||||
text("title", "Title", { maxLength: 60 }),
|
||||
textarea("description", "Description", { maxLength: 180, rows: 3 }),
|
||||
objectList(
|
||||
"steps",
|
||||
"Steps",
|
||||
[
|
||||
text("number", "Number", { maxLength: 8 }),
|
||||
text("title", "Title", { maxLength: 50 }),
|
||||
textarea("description", "Description", { maxLength: 180, rows: 3 }),
|
||||
checkbox("active", "Active"),
|
||||
],
|
||||
{ itemLabel: "Step" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "eligibility",
|
||||
label: "Eligibility",
|
||||
icon: "fas fa-check-circle",
|
||||
schema: object("eligibility", "Eligibility", [
|
||||
text("id", "Section ID", { maxLength: 40 }),
|
||||
text("title", "Title", { maxLength: 60 }),
|
||||
objectList(
|
||||
"cards",
|
||||
"Cards",
|
||||
[
|
||||
text("title", "Title", { maxLength: 50 }),
|
||||
icon("icon", "Icon"),
|
||||
stringList("items", "Items", {
|
||||
itemLabel: "Bullet Item",
|
||||
maxLength: 120,
|
||||
}),
|
||||
],
|
||||
{ itemLabel: "Card" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "tuition",
|
||||
label: "Tuition",
|
||||
icon: "fas fa-chart-column",
|
||||
schema: object("tuition", "Tuition", [
|
||||
text("id", "Section ID", { maxLength: 40 }),
|
||||
text("title", "Title", { maxLength: 60 }),
|
||||
text("chartTitle", "Chart Title", { maxLength: 60 }),
|
||||
textarea("chartDescription", "Chart Description", {
|
||||
maxLength: 140,
|
||||
rows: 3,
|
||||
}),
|
||||
objectList(
|
||||
"series",
|
||||
"Chart Series",
|
||||
[
|
||||
text("label", "Label", { maxLength: 40 }),
|
||||
{ key: "color", label: "Color", type: "color" },
|
||||
stringList("values", "Values", {
|
||||
itemLabel: "Point",
|
||||
fieldType: "number",
|
||||
}),
|
||||
],
|
||||
{ itemLabel: "Series" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "keyDates",
|
||||
label: "Key Dates",
|
||||
icon: "fas fa-calendar-days",
|
||||
schema: object("keyDates", "Key Dates", [
|
||||
text("id", "Section ID", { maxLength: 40 }),
|
||||
text("title", "Title", { maxLength: 60 }),
|
||||
stringList("columns", "Columns", {
|
||||
itemLabel: "Column",
|
||||
maxLength: 40,
|
||||
}),
|
||||
objectList(
|
||||
"rows",
|
||||
"Rows",
|
||||
[
|
||||
text("term", "Term", { maxLength: 40 }),
|
||||
text("applicationDeadline", "Application Deadline", {
|
||||
maxLength: 40,
|
||||
}),
|
||||
text("classesStart", "Classes Start", { maxLength: 40 }),
|
||||
],
|
||||
{ itemLabel: "Row" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "calculator",
|
||||
label: "Calculator",
|
||||
icon: "fas fa-calculator",
|
||||
schema: object("calculator", "Calculator", [
|
||||
text("title", "Title", { maxLength: 60 }),
|
||||
textarea("description", "Description", { maxLength: 120, rows: 3 }),
|
||||
stringList("modelOptions", "Model Options", {
|
||||
itemLabel: "Option",
|
||||
maxLength: 30,
|
||||
}),
|
||||
text("paceLabel", "Pace Label", { maxLength: 30 }),
|
||||
text("minPaceLabel", "Min Pace Label", { maxLength: 20 }),
|
||||
text("maxPaceLabel", "Max Pace Label", { maxLength: 20 }),
|
||||
text("resultLabel", "Result Label", { maxLength: 40 }),
|
||||
text("monthlyAmount", "Monthly Amount", { maxLength: 20 }),
|
||||
text("monthlySuffix", "Monthly Suffix", { maxLength: 10 }),
|
||||
icon("noteIcon", "Note Icon"),
|
||||
text("note", "Note", { maxLength: 60 }),
|
||||
object("cta", "CTA", linkFields("CTA")),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "scholarships",
|
||||
label: "Scholarships",
|
||||
icon: "fas fa-award",
|
||||
schema: object("scholarships", "Scholarships", [
|
||||
text("title", "Title", { maxLength: 50 }),
|
||||
icon("icon", "Icon"),
|
||||
objectList(
|
||||
"items",
|
||||
"Scholarship Items",
|
||||
[
|
||||
text("title", "Title", { maxLength: 50 }),
|
||||
text("amount", "Amount", { maxLength: 24 }),
|
||||
textarea("description", "Description", { maxLength: 160, rows: 3 }),
|
||||
],
|
||||
{ itemLabel: "Scholarship Item" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
policies: {
|
||||
key: "policies",
|
||||
title: "Policies Management",
|
||||
subtitle: "Edit content displayed on the policies page",
|
||||
routeBase: "/admin/policies",
|
||||
apiPath: "/api/policies",
|
||||
previewPath: "/policies",
|
||||
dataFile: "policies",
|
||||
imageType: "policies",
|
||||
tabs: [
|
||||
{
|
||||
key: "hero",
|
||||
label: "Hero",
|
||||
icon: "fas fa-scale-balanced",
|
||||
schema: object("hero", "Hero", [
|
||||
text("badge", "Badge", { maxLength: 40 }),
|
||||
icon("icon", "Icon", {
|
||||
helpText: "Policies uses icon-only controls and does not require image upload.",
|
||||
}),
|
||||
text("titlePrefix", "Title Prefix", { maxLength: 50 }),
|
||||
text("titleHighlight", "Title Highlight", { maxLength: 40 }),
|
||||
textarea("description", "Description", { maxLength: 220, rows: 4 }),
|
||||
text("lastUpdated", "Last Updated Label", { maxLength: 50 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "sidebar",
|
||||
label: "Sidebar",
|
||||
icon: "fas fa-bars",
|
||||
schema: object("sidebar", "Sidebar", [
|
||||
text("heading", "Heading", { maxLength: 30 }),
|
||||
text("helperText", "Helper Text", { maxLength: 60 }),
|
||||
text("contactLabel", "Contact Label", { maxLength: 40 }),
|
||||
url("contactHref", "Contact URL", { maxLength: 255 }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "policies",
|
||||
label: "Policies",
|
||||
icon: "fas fa-file-lines",
|
||||
schema: objectList(
|
||||
"policies",
|
||||
"Policies",
|
||||
[
|
||||
text("id", "Policy ID", { maxLength: 40 }),
|
||||
text("navLabel", "Navigation Label", { maxLength: 40 }),
|
||||
text("title", "Title", { maxLength: 70 }),
|
||||
text("effectiveDate", "Effective Date", { maxLength: 50 }),
|
||||
textarea("intro", "Intro", { maxLength: 260, rows: 4 }),
|
||||
variantList(
|
||||
"sections",
|
||||
"Sections",
|
||||
{
|
||||
text: {
|
||||
label: "Text Section",
|
||||
schema: object("section", "Text Section", [
|
||||
text("heading", "Heading", { maxLength: 60 }),
|
||||
objectList(
|
||||
"paragraphs",
|
||||
"Paragraphs",
|
||||
[
|
||||
textarea("text", "Text", {
|
||||
maxLength: 500,
|
||||
rows: 4,
|
||||
}),
|
||||
objectList(
|
||||
"links",
|
||||
"Links",
|
||||
[
|
||||
text("label", "Label", { maxLength: 50 }),
|
||||
url("href", "URL", { maxLength: 255 }),
|
||||
text("tabId", "Target Policy ID", {
|
||||
maxLength: 40,
|
||||
helpText:
|
||||
"Use this when the link should switch to another policy tab.",
|
||||
}),
|
||||
],
|
||||
{ itemLabel: "Link" },
|
||||
),
|
||||
],
|
||||
{ itemLabel: "Paragraph" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
list: {
|
||||
label: "List Section",
|
||||
schema: object("section", "List Section", [
|
||||
text("heading", "Heading", { maxLength: 60 }),
|
||||
textarea("intro", "Intro", { maxLength: 220, rows: 3 }),
|
||||
stringList("items", "Items", {
|
||||
itemLabel: "List Item",
|
||||
maxLength: 180,
|
||||
fieldType: "textarea",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
cards: {
|
||||
label: "Cards Section",
|
||||
schema: object("section", "Cards Section", [
|
||||
objectList(
|
||||
"cards",
|
||||
"Cards",
|
||||
[
|
||||
icon("icon", "Icon"),
|
||||
text("title", "Title", { maxLength: 50 }),
|
||||
textarea("description", "Description", {
|
||||
maxLength: 220,
|
||||
rows: 4,
|
||||
}),
|
||||
object("link", "Link", [
|
||||
text("label", "Label", { maxLength: 50 }),
|
||||
url("href", "URL", { maxLength: 255 }),
|
||||
text("tabId", "Target Policy ID", { maxLength: 40 }),
|
||||
]),
|
||||
],
|
||||
{ itemLabel: "Card" },
|
||||
),
|
||||
]),
|
||||
},
|
||||
},
|
||||
{ itemLabel: "Section" },
|
||||
),
|
||||
],
|
||||
{ itemLabel: "Policy" },
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -13,12 +13,7 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form
|
||||
action="<%= editorConfig.routeBase %>/update"
|
||||
method="POST"
|
||||
id="pageContentForm"
|
||||
class="content-with-fixed-buttons"
|
||||
>
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
@@ -27,13 +22,7 @@
|
||||
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link <%= activeTab === tab.key ? 'active' : '' %>"
|
||||
data-bs-toggle="tab"
|
||||
href="#<%= tab.key %>"
|
||||
role="tab"
|
||||
data-tab-key="<%= tab.key %>"
|
||||
>
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
@@ -41,12 +30,10 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<%- include("partials/tab-pane", { tab, activeTab }) %>
|
||||
<% }) %>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<%- include("partials/trust-banner-tab", { activeTab }) %>
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/grid-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,4 +57,8 @@
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<script src="/js/page-content-editor.js"></script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const iconOptions = Array.from(
|
||||
new Set(
|
||||
(config.tabs || [])
|
||||
.flatMap((tab) => collectIcons(tab.schema))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
ensureIconDatalist(iconOptions);
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex justify-content-between gap-3";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = null;
|
||||
if (schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "form-text text-end ms-auto";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
|
||||
if (schema.type === "array") return collectIcons(schema.itemSchema);
|
||||
if (schema.type === "variant") {
|
||||
return Object.values(schema.variants || {}).flatMap((variant) =>
|
||||
collectIcons(variant.schema),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureIconDatalist(options) {
|
||||
const existing = document.getElementById("cms-icon-options");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = "cms-icon-options";
|
||||
options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'grid' ? 'show active' : '' %>" id="grid" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-table-cells-large me-2"></i>Accreditation Grid</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'trustBanner' ? 'show active' : '' %>" id="trustBanner" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-shield-check me-2"></i>Trust Banner</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="trustBanner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<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">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/process-tab", { activeTab }) %>
|
||||
<%- include("partials/eligibility-tab", { activeTab }) %>
|
||||
<%- include("partials/tuition-tab", { activeTab }) %>
|
||||
<%- include("partials/key-dates-tab", { activeTab }) %>
|
||||
<%- include("partials/calculator-tab", { activeTab }) %>
|
||||
<%- include("partials/scholarships-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'calculator' ? 'show active' : '' %>" id="calculator" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-calculator me-2"></i>Calculator</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="calculator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const iconOptions = Array.from(
|
||||
new Set(
|
||||
(config.tabs || [])
|
||||
.flatMap((tab) => collectIcons(tab.schema))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
ensureIconDatalist(iconOptions);
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex justify-content-between gap-3";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = null;
|
||||
if (schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "form-text text-end ms-auto";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
|
||||
if (schema.type === "array") return collectIcons(schema.itemSchema);
|
||||
if (schema.type === "variant") {
|
||||
return Object.values(schema.variants || {}).flatMap((variant) =>
|
||||
collectIcons(variant.schema),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureIconDatalist(options) {
|
||||
const existing = document.getElementById("cms-icon-options");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = "cms-icon-options";
|
||||
options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'eligibility' ? 'show active' : '' %>" id="eligibility" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-check-circle me-2"></i>Eligibility</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="eligibility"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'keyDates' ? 'show active' : '' %>" id="keyDates" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-calendar-days me-2"></i>Key Dates</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="keyDates"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'process' ? 'show active' : '' %>" id="process" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-list-ol me-2"></i>Admissions Process</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="process"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'scholarships' ? 'show active' : '' %>" id="scholarships" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-award me-2"></i>Scholarships</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="scholarships"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'tuition' ? 'show active' : '' %>" id="tuition" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-chart-column me-2"></i>Tuition</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="tuition"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<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">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/highlight-tab", { activeTab }) %>
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/filters-tab", { activeTab }) %>
|
||||
<%- include("partials/timeline-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const iconOptions = Array.from(
|
||||
new Set(
|
||||
(config.tabs || [])
|
||||
.flatMap((tab) => collectIcons(tab.schema))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
ensureIconDatalist(iconOptions);
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex justify-content-between gap-3";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = null;
|
||||
if (schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "form-text text-end ms-auto";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
|
||||
if (schema.type === "array") return collectIcons(schema.itemSchema);
|
||||
if (schema.type === "variant") {
|
||||
return Object.values(schema.variants || {}).flatMap((variant) =>
|
||||
collectIcons(variant.schema),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureIconDatalist(options) {
|
||||
const existing = document.getElementById("cms-icon-options");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = "cms-icon-options";
|
||||
options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'filters' ? 'show active' : '' %>" id="filters" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-filter me-2"></i>Filter Controls</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="filters"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'highlight' ? 'show active' : '' %>" id="highlight" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-star me-2"></i>Highlight Bar</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="highlight"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'timeline' ? 'show active' : '' %>" id="timeline" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-clock-rotate-left me-2"></i>Timeline</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="timeline"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<div class="tab-pane fade <%= activeTab === tab.key ? 'show active' : '' %>" id="<%= tab.key %>" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="<%= tab.key %>"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="/admin/partnerships/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<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 <%= activeTab === 'hero' ? 'active' : '' %>" data-bs-toggle="tab" href="#hero" role="tab" data-tab-key="hero">
|
||||
<i class="fas fa-image me-2"></i>Hero
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === 'directory' ? 'active' : '' %>" data-bs-toggle="tab" href="#directory" role="tab" data-tab-key="directory">
|
||||
<i class="fas fa-handshake me-2"></i>Partner Directory
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === 'cta' ? 'active' : '' %>" data-bs-toggle="tab" href="#cta" role="tab" data-tab-key="cta">
|
||||
<i class="fas fa-bullhorn me-2"></i>Call To Action
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === 'inquiryForm' ? 'active' : '' %>" data-bs-toggle="tab" href="#inquiryForm" role="tab" data-tab-key="inquiryForm">
|
||||
<i class="fas fa-envelope me-2"></i>Inquiry Form
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/hero-tab", { activeTab, data, backendUrl }) %>
|
||||
<%- include("partials/directory-tab", { activeTab, data }) %>
|
||||
<%- include("partials/cta-tab", { activeTab, data }) %>
|
||||
<%- include("partials/inquiry-form-tab", { activeTab, data }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="button" class="btn btn-secondary" id="resetPartnershipsForm">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include("partials/templates") %>
|
||||
|
||||
<script>
|
||||
window.partnershipsPageData = <%- JSON.stringify(data) %>;
|
||||
window.partnershipsBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'cta' ? 'show active' : '' %>" id="cta" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-bullhorn me-2"></i>Call To Action</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Headline</label>
|
||||
<input class="form-control" id="ctaHeading" maxlength="80" value="<%= data.cta?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Supporting text</label>
|
||||
<textarea class="form-control" id="ctaDescription" rows="4" maxlength="220"><%= data.cta?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Button label</label>
|
||||
<input class="form-control" id="ctaButtonLabel" maxlength="40" value="<%= data.cta?.buttonLabel || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'directory' ? 'show active' : '' %>" id="directory" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-handshake me-2"></i>Partner Directory</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">Section heading</label>
|
||||
<input class="form-control" id="directoryHeading" maxlength="70" value="<%= data.directory?.heading || '' %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Load more button label</label>
|
||||
<input class="form-control" id="directoryLoadMoreLabel" maxlength="40" value="<%= data.directory?.loadMoreLabel || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Section description</label>
|
||||
<textarea class="form-control" id="directoryDescription" rows="3" maxlength="180"><%= data.directory?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-3 bg-light-subtle p-3 mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">Category tabs</label>
|
||||
<div class="form-text mt-0">The frontend shows up to 3 direct tabs. Additional tabs move into a More dropdown.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="addDirectoryTabBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add Tab
|
||||
</button>
|
||||
</div>
|
||||
<div id="directoryTabsList"></div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-3 bg-light-subtle p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">Partners</label>
|
||||
<div class="form-text mt-0">Use a short unique partner key so card state stays stable.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="addPartnerBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add Partner
|
||||
</button>
|
||||
</div>
|
||||
<div id="partnersList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
<script>
|
||||
(function () {
|
||||
const initialData = window.partnershipsPageData;
|
||||
const backendUrl = (window.partnershipsBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
const directoryTabsList = document.getElementById("directoryTabsList");
|
||||
const partnersList = document.getElementById("partnersList");
|
||||
const inquiryFieldsList = document.getElementById("inquiryFieldsList");
|
||||
|
||||
if (!initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const templates = {
|
||||
tab: document.getElementById("directoryTabTemplate"),
|
||||
partner: document.getElementById("partnerTemplate"),
|
||||
inquiryField: document.getElementById("inquiryFieldTemplate"),
|
||||
inquiryOption: document.getElementById("inquiryOptionTemplate"),
|
||||
};
|
||||
|
||||
bindStaticEvents();
|
||||
renderAll();
|
||||
|
||||
function bindStaticEvents() {
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
if (!tabKey) return;
|
||||
activeTabInput.value = tabKey;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState({}, "", `${url.pathname}?${url.searchParams.toString()}`);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("addDirectoryTabBtn")?.addEventListener("click", function () {
|
||||
state.directory.tabs.push("");
|
||||
renderDirectoryTabs();
|
||||
});
|
||||
|
||||
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
|
||||
state.directory.partners.push({
|
||||
id: "",
|
||||
name: "",
|
||||
category: "",
|
||||
summary: "",
|
||||
logo: "",
|
||||
logoAlt: "",
|
||||
about: "",
|
||||
collabType: "",
|
||||
benefits: "",
|
||||
});
|
||||
renderPartners();
|
||||
});
|
||||
|
||||
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
|
||||
state.inquiryForm.fields.push({
|
||||
id: "",
|
||||
label: "",
|
||||
placeholder: "",
|
||||
type: "text",
|
||||
width: "full",
|
||||
required: true,
|
||||
options: [],
|
||||
});
|
||||
renderInquiryFields();
|
||||
});
|
||||
|
||||
document.getElementById("resetPartnershipsForm")?.addEventListener("click", function () {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
syncStaticFields();
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-upload-target]").forEach((button) => {
|
||||
button.addEventListener("click", function () {
|
||||
const targetId = button.getAttribute("data-upload-target");
|
||||
const previewId = button.getAttribute("data-preview-target");
|
||||
const input = document.getElementById(targetId);
|
||||
const preview = document.getElementById(previewId);
|
||||
openImagePicker(function (path) {
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function syncStaticFields() {
|
||||
state.hero = {
|
||||
badge: getValue("heroBadge"),
|
||||
title: getValue("heroTitle"),
|
||||
description: getValue("heroDescription"),
|
||||
linkLabel: getValue("heroLinkLabel"),
|
||||
image: getValue("heroImage"),
|
||||
imageAlt: getValue("heroImageAlt"),
|
||||
};
|
||||
|
||||
state.directory.heading = getValue("directoryHeading");
|
||||
state.directory.description = getValue("directoryDescription");
|
||||
state.directory.loadMoreLabel = getValue("directoryLoadMoreLabel");
|
||||
|
||||
state.cta = {
|
||||
heading: getValue("ctaHeading"),
|
||||
description: getValue("ctaDescription"),
|
||||
buttonLabel: getValue("ctaButtonLabel"),
|
||||
};
|
||||
|
||||
state.inquiryForm.title = getValue("inquiryTitle");
|
||||
state.inquiryForm.submitLabel = getValue("inquirySubmitLabel");
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderDirectoryTabs();
|
||||
renderPartners();
|
||||
renderInquiryFields();
|
||||
}
|
||||
|
||||
function renderDirectoryTabs() {
|
||||
directoryTabsList.innerHTML = "";
|
||||
|
||||
state.directory.tabs.forEach((tab, index) => {
|
||||
const node = cloneTemplate(templates.tab);
|
||||
const input = node.querySelector('[data-field="label"]');
|
||||
input.value = tab || "";
|
||||
input.addEventListener("input", function () {
|
||||
state.directory.tabs[index] = input.value;
|
||||
refreshPartnerCategoryLists();
|
||||
});
|
||||
|
||||
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
state.directory.tabs.splice(index, 1);
|
||||
renderDirectoryTabs();
|
||||
renderPartners();
|
||||
});
|
||||
|
||||
directoryTabsList.appendChild(node);
|
||||
});
|
||||
|
||||
initSortable(directoryTabsList, state.directory.tabs, renderDirectoryTabs, '[data-item="directory-tab"]');
|
||||
}
|
||||
|
||||
function renderPartners() {
|
||||
partnersList.innerHTML = "";
|
||||
|
||||
state.directory.partners.forEach((partner, index) => {
|
||||
const node = cloneTemplate(templates.partner);
|
||||
const title = node.querySelector("[data-title]");
|
||||
const subtitle = node.querySelector("[data-subtitle]");
|
||||
const categoryInput = node.querySelector('[data-field="category"]');
|
||||
const categoryListId = `partner-category-options-${index}`;
|
||||
|
||||
title.textContent = partner.name || `Partner ${index + 1}`;
|
||||
subtitle.textContent = partner.category || "";
|
||||
|
||||
node.querySelectorAll("[data-field]").forEach((input) => {
|
||||
const field = input.getAttribute("data-field");
|
||||
input.value = partner[field] || "";
|
||||
input.addEventListener("input", function () {
|
||||
partner[field] = input.value;
|
||||
if (field === "name") {
|
||||
title.textContent = input.value || `Partner ${index + 1}`;
|
||||
}
|
||||
if (field === "category") {
|
||||
subtitle.textContent = input.value || "";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
categoryInput.setAttribute("list", categoryListId);
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = categoryListId;
|
||||
state.directory.tabs.forEach((tab) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = tab;
|
||||
dataList.appendChild(option);
|
||||
});
|
||||
node.appendChild(dataList);
|
||||
|
||||
const preview = node.querySelector("[data-preview]");
|
||||
const logoInput = node.querySelector('[data-field="logo"]');
|
||||
if (partner.logo) {
|
||||
preview.src = resolveImageUrl(partner.logo);
|
||||
preview.classList.remove("d-none");
|
||||
}
|
||||
|
||||
node.querySelector("[data-upload-button]").addEventListener("click", function () {
|
||||
openImagePicker(function (path) {
|
||||
partner.logo = path;
|
||||
logoInput.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.remove("d-none");
|
||||
});
|
||||
});
|
||||
|
||||
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
state.directory.partners.splice(index, 1);
|
||||
renderPartners();
|
||||
});
|
||||
|
||||
partnersList.appendChild(node);
|
||||
});
|
||||
|
||||
initSortable(partnersList, state.directory.partners, renderPartners, '[data-item="partner"]');
|
||||
}
|
||||
|
||||
function refreshPartnerCategoryLists() {
|
||||
partnersList.querySelectorAll("datalist").forEach((list) => list.remove());
|
||||
renderPartners();
|
||||
}
|
||||
|
||||
function renderInquiryFields() {
|
||||
inquiryFieldsList.innerHTML = "";
|
||||
|
||||
state.inquiryForm.fields.forEach((field, index) => {
|
||||
const node = cloneTemplate(templates.inquiryField);
|
||||
const title = node.querySelector("[data-title]");
|
||||
const subtitle = node.querySelector("[data-subtitle]");
|
||||
const optionsWrap = node.querySelector("[data-options-wrap]");
|
||||
const optionsList = node.querySelector("[data-options-list]");
|
||||
const typeSelect = node.querySelector('[data-field="type"]');
|
||||
|
||||
title.textContent = field.label || `Field ${index + 1}`;
|
||||
subtitle.textContent = field.type || "";
|
||||
|
||||
node.querySelectorAll("[data-field]").forEach((input) => {
|
||||
const key = input.getAttribute("data-field");
|
||||
|
||||
if (input.type === "checkbox") {
|
||||
input.checked = Boolean(field[key]);
|
||||
input.addEventListener("change", function () {
|
||||
field[key] = input.checked;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = field[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
field[key] = input.value;
|
||||
if (key === "label") {
|
||||
title.textContent = input.value || `Field ${index + 1}`;
|
||||
}
|
||||
if (key === "type") {
|
||||
subtitle.textContent = input.value || "";
|
||||
toggleOptions();
|
||||
}
|
||||
});
|
||||
if (input.tagName === "SELECT") {
|
||||
input.addEventListener("change", function () {
|
||||
field[key] = input.value;
|
||||
if (key === "type") {
|
||||
subtitle.textContent = input.value || "";
|
||||
if (input.value !== "select") {
|
||||
field.options = [];
|
||||
}
|
||||
toggleOptions();
|
||||
renderInquiryFields();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
node.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
state.inquiryForm.fields.splice(index, 1);
|
||||
renderInquiryFields();
|
||||
});
|
||||
|
||||
node.querySelector("[data-add-option]").addEventListener("click", function () {
|
||||
field.options = Array.isArray(field.options) ? field.options : [];
|
||||
field.options.push("");
|
||||
renderInquiryFields();
|
||||
});
|
||||
|
||||
function toggleOptions() {
|
||||
optionsWrap.classList.toggle("d-none", typeSelect.value !== "select");
|
||||
}
|
||||
|
||||
function renderOptions() {
|
||||
optionsList.innerHTML = "";
|
||||
(field.options || []).forEach((optionValue, optionIndex) => {
|
||||
const optionNode = cloneTemplate(templates.inquiryOption);
|
||||
const optionInput = optionNode.querySelector("[data-option-value]");
|
||||
optionInput.value = optionValue || "";
|
||||
optionInput.addEventListener("input", function () {
|
||||
field.options[optionIndex] = optionInput.value;
|
||||
});
|
||||
optionNode.querySelector("[data-remove-item]").addEventListener("click", function () {
|
||||
field.options.splice(optionIndex, 1);
|
||||
renderInquiryFields();
|
||||
});
|
||||
optionsList.appendChild(optionNode);
|
||||
});
|
||||
initSortable(optionsList, field.options, renderInquiryFields, '[data-item="inquiry-option"]');
|
||||
}
|
||||
|
||||
toggleOptions();
|
||||
renderOptions();
|
||||
inquiryFieldsList.appendChild(node);
|
||||
});
|
||||
|
||||
initSortable(inquiryFieldsList, state.inquiryForm.fields, renderInquiryFields, '[data-item="inquiry-field"]');
|
||||
}
|
||||
|
||||
function initSortable(container, list, rerender, draggableSelector) {
|
||||
if (!window.Sortable || !container) return;
|
||||
if (container._sortableInstance) {
|
||||
container._sortableInstance.destroy();
|
||||
}
|
||||
container._sortableInstance = window.Sortable.create(container, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
draggable: draggableSelector,
|
||||
onEnd: function (event) {
|
||||
if (event.oldIndex === event.newIndex) return;
|
||||
const moved = list.splice(event.oldIndex, 1)[0];
|
||||
list.splice(event.newIndex, 0, moved);
|
||||
rerender();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cloneTemplate(template) {
|
||||
return template.content.firstElementChild.cloneNode(true);
|
||||
}
|
||||
|
||||
function getValue(id) {
|
||||
return document.getElementById(id)?.value || "";
|
||||
}
|
||||
|
||||
function openImagePicker(onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch("/admin/upload/image?imageType=partnerships", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container = document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(title)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-image me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Eyebrow label</label>
|
||||
<input class="form-control" id="heroBadge" maxlength="40" value="<%= data.hero?.badge || '' %>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Scroll link label</label>
|
||||
<input class="form-control" id="heroLinkLabel" maxlength="40" value="<%= data.hero?.linkLabel || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Headline</label>
|
||||
<input class="form-control" id="heroTitle" maxlength="90" value="<%= data.hero?.title || '' %>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Supporting text</label>
|
||||
<textarea class="form-control" id="heroDescription" rows="4" maxlength="220"><%= data.hero?.description || '' %></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Hero image</label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" id="heroImage" value="<%= data.hero?.image || '' %>">
|
||||
<button class="btn btn-outline-primary" type="button" data-upload-target="heroImage" data-preview-target="heroImagePreview">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Recommended 720x630 px</div>
|
||||
<img id="heroImagePreview" src="<%= data.hero?.image ? `${backendUrl}${data.hero.image}` : '' %>" class="img-thumbnail mt-2 <%= data.hero?.image ? '' : 'd-none' %>" style="max-height: 200px;">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Hero image alt text</label>
|
||||
<input class="form-control" id="heroImageAlt" maxlength="120" value="<%= data.hero?.imageAlt || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'inquiryForm' ? 'show active' : '' %>" id="inquiryForm" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-envelope me-2"></i>Inquiry Form</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">Modal title</label>
|
||||
<input class="form-control" id="inquiryTitle" maxlength="60" value="<%= data.inquiryForm?.title || '' %>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Submit button label</label>
|
||||
<input class="form-control" id="inquirySubmitLabel" maxlength="40" value="<%= data.inquiryForm?.submitLabel || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-3 bg-light-subtle p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">Form fields</label>
|
||||
<div class="form-text mt-0">Manage labels, placeholders, type, width, and dropdown options.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="addInquiryFieldBtn">
|
||||
<i class="fas fa-plus me-1"></i>Add Field
|
||||
</button>
|
||||
</div>
|
||||
<div id="inquiryFieldsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<template id="directoryTabTemplate">
|
||||
<div class="card shadow-sm border-0 mb-3" data-item="directory-tab">
|
||||
<div class="bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<div class="fw-semibold">Category Tab</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item>
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<label class="form-label fw-semibold">Tab label</label>
|
||||
<input class="form-control" data-field="label" maxlength="30" placeholder="Industry">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="partnerTemplate">
|
||||
<div class="card shadow-sm border-0 mb-3" data-item="partner">
|
||||
<div class="bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<div>
|
||||
<div class="fw-semibold" data-title>Partner</div>
|
||||
<div class="small text-muted" data-subtitle></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item>
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Partner key</label>
|
||||
<input class="form-control" data-field="id" maxlength="50">
|
||||
<div class="form-text">Use a short unique key.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Partner name</label>
|
||||
<input class="form-control" data-field="name" maxlength="90">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Category</label>
|
||||
<input class="form-control" data-field="category" list="">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Card summary</label>
|
||||
<textarea class="form-control" data-field="summary" rows="3" maxlength="130"></textarea>
|
||||
<div class="form-text">The card preview is capped at 130 characters.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Partner logo</label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" data-field="logo">
|
||||
<button class="btn btn-outline-primary" type="button" data-upload-button>
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Recommended 105x80 px minimum visible ratio</div>
|
||||
<img class="img-thumbnail mt-2 d-none" data-preview style="max-height: 180px;">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Logo alt text</label>
|
||||
<input class="form-control" data-field="logoAlt" maxlength="120">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Collaboration type</label>
|
||||
<input class="form-control" data-field="collabType" maxlength="40">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">About text</label>
|
||||
<textarea class="form-control" data-field="about" rows="5" maxlength="600"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Benefits</label>
|
||||
<textarea class="form-control" data-field="benefits" rows="4" maxlength="240"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="inquiryFieldTemplate">
|
||||
<div class="card shadow-sm border-0 mb-3" data-item="inquiry-field">
|
||||
<div class="bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<div>
|
||||
<div class="fw-semibold" data-title>Field</div>
|
||||
<div class="small text-muted" data-subtitle></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item>
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Field key</label>
|
||||
<input class="form-control" data-field="id" maxlength="40">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Field label</label>
|
||||
<input class="form-control" data-field="label" maxlength="40">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Placeholder text</label>
|
||||
<input class="form-control" data-field="placeholder" maxlength="80">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Field type</label>
|
||||
<select class="form-select" data-field="type">
|
||||
<option value="text">Single line text</option>
|
||||
<option value="textarea">Paragraph</option>
|
||||
<option value="select">Dropdown</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Field width</label>
|
||||
<select class="form-select" data-field="width">
|
||||
<option value="half">Half width</option>
|
||||
<option value="full">Full width</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" data-field="required">
|
||||
<label class="form-check-label fw-semibold">Required field</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12" data-options-wrap>
|
||||
<div class="border rounded-3 p-3 bg-light">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="form-label fw-semibold mb-0">Dropdown options</label>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" data-add-option>
|
||||
<i class="fas fa-plus me-1"></i>Add Option
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text mb-2">Only used when the field type is Dropdown.</div>
|
||||
<div data-options-list></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="inquiryOptionTemplate">
|
||||
<div class="input-group mb-2" data-item="inquiry-option">
|
||||
<button type="button" class="btn btn-light border drag-handle" title="Drag to reorder">
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
</button>
|
||||
<input class="form-control" data-option-value maxlength="50" placeholder="Option label">
|
||||
<button type="button" class="btn btn-outline-danger" data-remove-item>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<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">
|
||||
<% editorConfig.tabs.forEach((tab) => { %>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <%= activeTab === tab.key ? 'active' : '' %>" data-bs-toggle="tab" href="#<%= tab.key %>" role="tab" data-tab-key="<%= tab.key %>">
|
||||
<i class="<%= tab.icon %> me-2"></i><%= tab.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/hero-tab", { activeTab }) %>
|
||||
<%- include("partials/sidebar-tab", { activeTab }) %>
|
||||
<%- include("partials/policies-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const iconOptions = Array.from(
|
||||
new Set(
|
||||
(config.tabs || [])
|
||||
.flatMap((tab) => collectIcons(tab.schema))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
ensureIconDatalist(iconOptions);
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex justify-content-between gap-3";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = null;
|
||||
if (schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "form-text text-end ms-auto";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
|
||||
if (schema.type === "array") return collectIcons(schema.itemSchema);
|
||||
if (schema.type === "variant") {
|
||||
return Object.values(schema.variants || {}).flatMap((variant) =>
|
||||
collectIcons(variant.schema),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureIconDatalist(options) {
|
||||
const existing = document.getElementById("cms-icon-options");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = "cms-icon-options";
|
||||
options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'hero' ? 'show active' : '' %>" id="hero" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-scale-balanced me-2"></i>Hero</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="hero"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'policies' ? 'show active' : '' %>" id="policies" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-file-lines me-2"></i>Policies</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="policies"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
<script>
|
||||
(function () {
|
||||
const config = window.pageEditorConfig;
|
||||
const initialData = window.pageEditorData;
|
||||
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
|
||||
const form = document.getElementById("cmsEditorForm");
|
||||
const pageJsonInput = document.getElementById("pageJson");
|
||||
const activeTabInput = document.getElementById("activeTabInput");
|
||||
|
||||
if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = JSON.parse(JSON.stringify(initialData));
|
||||
const iconOptions = Array.from(
|
||||
new Set(
|
||||
(config.tabs || [])
|
||||
.flatMap((tab) => collectIcons(tab.schema))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
ensureIconDatalist(iconOptions);
|
||||
renderAllSections();
|
||||
|
||||
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
|
||||
tabTrigger.addEventListener("shown.bs.tab", function () {
|
||||
const tabKey = this.dataset.tabKey;
|
||||
|
||||
if (!tabKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeTabInput.value = tabKey;
|
||||
updateTabUrl(tabKey);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
pageJsonInput.value = JSON.stringify(state);
|
||||
});
|
||||
|
||||
form.addEventListener("reset", function () {
|
||||
window.setTimeout(function () {
|
||||
Object.keys(state).forEach((key) => delete state[key]);
|
||||
Object.assign(state, JSON.parse(JSON.stringify(initialData)));
|
||||
renderAllSections();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function renderAllSections() {
|
||||
config.tabs.forEach((tab) => renderSection(tab.key));
|
||||
}
|
||||
|
||||
function updateTabUrl(tabKey) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabKey);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${url.pathname}?${url.searchParams.toString()}${url.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(tabKey) {
|
||||
const tab = config.tabs.find((item) => item.key === tabKey);
|
||||
const container = document.querySelector(
|
||||
`.page-editor-section[data-section-key="${tabKey}"]`,
|
||||
);
|
||||
|
||||
if (!tab || !container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
|
||||
path: tab.schema.key,
|
||||
root: state,
|
||||
item: null,
|
||||
});
|
||||
}
|
||||
|
||||
function renderField(schema, container, parent, key, tabKey, context) {
|
||||
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
if (!isObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
|
||||
const groupWrapper = document.createElement("div");
|
||||
groupWrapper.className = "row g-3";
|
||||
container.appendChild(groupWrapper);
|
||||
|
||||
(schema.fields || []).forEach((field) => {
|
||||
renderField(field, groupWrapper, parent[key], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: parent[key],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(parent[key])) {
|
||||
parent[key] = [];
|
||||
}
|
||||
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const card = document.createElement("div");
|
||||
card.className = "border rounded-3 bg-light-subtle p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "d-flex justify-content-between align-items-center mb-3 gap-3";
|
||||
header.innerHTML = `
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">${escapeHtml(schema.label)}</label>
|
||||
${schema.helpText ? `<div class="form-text mt-0">${escapeHtml(schema.helpText)}</div>` : ""}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-plus me-1"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.querySelector("button").addEventListener("click", function () {
|
||||
parent[key].push(createDefaultValue(schema.itemSchema));
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (parent[key].length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "text-muted small";
|
||||
empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`;
|
||||
card.appendChild(empty);
|
||||
} else {
|
||||
const list = document.createElement("div");
|
||||
list.className = "page-editor-array-list";
|
||||
card.appendChild(list);
|
||||
|
||||
parent[key].forEach((item, index) => {
|
||||
const itemCard = document.createElement("div");
|
||||
itemCard.className = "card shadow-sm border-0 mb-3";
|
||||
itemCard.dataset.index = String(index);
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "bg-white border-bottom px-3 py-3 d-flex justify-content-between align-items-center gap-2 flex-wrap";
|
||||
|
||||
const title = getArrayItemTitle(schema, item, index);
|
||||
const subtitle = getArrayItemSubtitle(schema, item);
|
||||
itemHeader.innerHTML = `
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
${
|
||||
schema.sortable
|
||||
? '<button type="button" class="btn btn-light btn-sm border drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></button>'
|
||||
: ""
|
||||
}
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(title)}</div>
|
||||
${subtitle ? `<div class="small text-muted">${escapeHtml(subtitle)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
${renderItemActions(schema.itemActions, item)}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-item="true">
|
||||
<i class="fas fa-trash-alt me-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemHeader
|
||||
.querySelector('[data-remove-item="true"]')
|
||||
.addEventListener("click", function () {
|
||||
parent[key].splice(index, 1);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
});
|
||||
|
||||
itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => {
|
||||
actionButton.addEventListener("click", function () {
|
||||
window.location.href = actionButton.dataset.itemHref;
|
||||
});
|
||||
});
|
||||
|
||||
const itemBody = document.createElement("div");
|
||||
itemBody.className = "card-body";
|
||||
|
||||
if (schema.itemSchema.type === "primitive") {
|
||||
renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context);
|
||||
} else if (schema.itemSchema.type === "variant") {
|
||||
renderVariantArrayItem(
|
||||
schema.itemSchema,
|
||||
itemBody,
|
||||
parent[key],
|
||||
index,
|
||||
tabKey,
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const bodyRow = document.createElement("div");
|
||||
bodyRow.className = "row g-3";
|
||||
itemBody.appendChild(bodyRow);
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
renderField(field, bodyRow, parent[key][index], field.key, tabKey, {
|
||||
path: appendPath(context.path, `${index}.${field.key}`),
|
||||
root: context.root,
|
||||
item: parent[key][index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
itemCard.appendChild(itemHeader);
|
||||
itemCard.appendChild(itemBody);
|
||||
list.appendChild(itemCard);
|
||||
});
|
||||
|
||||
if (schema.sortable && window.Sortable) {
|
||||
window.Sortable.create(list, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd: function (event) {
|
||||
if (
|
||||
typeof event.oldIndex !== "number" ||
|
||||
typeof event.newIndex !== "number" ||
|
||||
event.oldIndex === event.newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedItem = parent[key].splice(event.oldIndex, 1)[0];
|
||||
parent[key].splice(event.newIndex, 0, movedItem);
|
||||
applyAutoSequenceToArray(schema, parent[key]);
|
||||
renderSection(tabKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
col.appendChild(card);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "checkbox") {
|
||||
renderCheckbox(schema, container, parent, key);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLeafField(schema, container, parent, key, context);
|
||||
}
|
||||
|
||||
function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) {
|
||||
const fieldSchema = arraySchema.itemSchema;
|
||||
const row = document.createElement("div");
|
||||
row.className = "row g-3";
|
||||
container.appendChild(row);
|
||||
|
||||
const holder = { value: targetArray[index] || "" };
|
||||
renderLeafField(
|
||||
{
|
||||
key: "value",
|
||||
label: fieldSchema.label || arraySchema.itemLabel || "Value",
|
||||
type: fieldSchema.fieldType || "text",
|
||||
maxLength: fieldSchema.maxLength,
|
||||
placeholder: fieldSchema.placeholder,
|
||||
helpText: fieldSchema.helpText,
|
||||
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
|
||||
},
|
||||
row,
|
||||
holder,
|
||||
"value",
|
||||
{
|
||||
path: appendPath(context.path, String(index)),
|
||||
root: context.root,
|
||||
item: holder,
|
||||
},
|
||||
);
|
||||
|
||||
const input = row.querySelector("input, textarea, select");
|
||||
if (input) {
|
||||
const sync = function () {
|
||||
targetArray[index] =
|
||||
fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value;
|
||||
};
|
||||
input.addEventListener("input", sync);
|
||||
input.addEventListener("change", sync);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) {
|
||||
const item = targetArray[index];
|
||||
if (!isObject(item)) {
|
||||
targetArray[index] = {};
|
||||
}
|
||||
|
||||
const currentType =
|
||||
targetArray[index][variantSchema.discriminator] ||
|
||||
variantSchema.options[0].value;
|
||||
targetArray[index][variantSchema.discriminator] = currentType;
|
||||
|
||||
const currentVariant = variantSchema.variants[currentType];
|
||||
|
||||
const typeRow = document.createElement("div");
|
||||
typeRow.className = "row g-3 mb-2";
|
||||
container.appendChild(typeRow);
|
||||
|
||||
renderLeafField(
|
||||
{
|
||||
key: variantSchema.discriminator,
|
||||
label: "Section type",
|
||||
type: "select",
|
||||
options: variantSchema.options,
|
||||
},
|
||||
typeRow,
|
||||
targetArray[index],
|
||||
variantSchema.discriminator,
|
||||
context,
|
||||
);
|
||||
|
||||
const selectInput = typeRow.querySelector("select");
|
||||
if (selectInput) {
|
||||
selectInput.addEventListener("change", function () {
|
||||
const newType = this.value;
|
||||
targetArray[index] = { type: newType };
|
||||
renderSection(tabKey);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentVariant && currentVariant.schema) {
|
||||
const sectionRow = document.createElement("div");
|
||||
sectionRow.className = "row g-3";
|
||||
container.appendChild(sectionRow);
|
||||
|
||||
(currentVariant.schema.fields || []).forEach((field) => {
|
||||
renderField(field, sectionRow, targetArray[index], field.key, tabKey, {
|
||||
path: appendPath(context.path, field.key),
|
||||
root: context.root,
|
||||
item: targetArray[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeafField(schema, container, parent, key, context) {
|
||||
if (schema.type === "hidden") {
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.defaultValue || "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parent[key] === "undefined" || parent[key] === null) {
|
||||
parent[key] = schema.type === "number" ? 0 : "";
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || inferColClass(schema.type));
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
col.appendChild(label);
|
||||
|
||||
if (schema.type === "textarea") {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = schema.rows || 4;
|
||||
textarea.value = parent[key] || "";
|
||||
if (schema.placeholder) textarea.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) textarea.maxLength = schema.maxLength;
|
||||
textarea.addEventListener("input", function () {
|
||||
parent[key] = textarea.value;
|
||||
updateCounter(counter, textarea.value.length, schema.maxLength);
|
||||
});
|
||||
col.appendChild(textarea);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "image") {
|
||||
const group = document.createElement("div");
|
||||
group.className = "input-group";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = parent[key] || "";
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.src = resolveImageUrl(input.value);
|
||||
preview.classList.toggle("d-none", !input.value);
|
||||
});
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-outline-primary";
|
||||
button.innerHTML = '<i class="fas fa-upload me-1"></i>Upload';
|
||||
button.addEventListener("click", function () {
|
||||
openImagePicker(schema.imageType || config.imageType, function (path) {
|
||||
parent[key] = path;
|
||||
input.value = path;
|
||||
preview.src = resolveImageUrl(path);
|
||||
preview.classList.toggle("d-none", !path);
|
||||
});
|
||||
});
|
||||
|
||||
group.appendChild(input);
|
||||
group.appendChild(button);
|
||||
col.appendChild(group);
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "img-thumbnail uploaded-preview mt-2";
|
||||
preview.style.maxHeight = "200px";
|
||||
preview.src = resolveImageUrl(parent[key]);
|
||||
preview.classList.toggle("d-none", !parent[key]);
|
||||
col.appendChild(preview);
|
||||
|
||||
appendHelp(col, schema, parent[key], schema.imageHint);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "icon") {
|
||||
renderIconField(schema, col, parent, key);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const input = document.createElement("select");
|
||||
input.className = "form-select";
|
||||
const options = resolveOptions(schema, context.root);
|
||||
options.forEach((option) => {
|
||||
const optionEl = document.createElement("option");
|
||||
if (typeof option === "string") {
|
||||
optionEl.value = option;
|
||||
optionEl.textContent = option;
|
||||
} else {
|
||||
optionEl.value = option.value;
|
||||
optionEl.textContent = option.label;
|
||||
}
|
||||
input.appendChild(optionEl);
|
||||
});
|
||||
input.value = parent[key] || input.options[0]?.value || "";
|
||||
parent[key] = input.value;
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.value;
|
||||
renderAllSections();
|
||||
});
|
||||
col.appendChild(input);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type === "combobox") {
|
||||
const input = document.createElement("input");
|
||||
const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`;
|
||||
input.className = "form-control";
|
||||
input.type = "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
input.setAttribute("list", listId);
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
updateCounter(counter, input.value.length, schema.maxLength);
|
||||
});
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = listId;
|
||||
resolveOptions(schema, context.root).forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = typeof option === "string" ? option : option.value;
|
||||
item.label = typeof option === "string" ? option : option.label;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
col.appendChild(dataList);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-control";
|
||||
input.type =
|
||||
schema.type === "number" || schema.type === "color" ? schema.type : "text";
|
||||
input.value = parent[key] || "";
|
||||
if (schema.placeholder) input.placeholder = schema.placeholder;
|
||||
if (schema.maxLength) input.maxLength = schema.maxLength;
|
||||
if (schema.step) input.step = schema.step;
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
|
||||
updateCounter(counter, String(input.value || "").length, schema.maxLength);
|
||||
});
|
||||
|
||||
col.appendChild(input);
|
||||
const counter = appendHelp(col, schema, parent[key]);
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function renderIconField(schema, col, parent, key) {
|
||||
const selected = parent[key] || "";
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "border rounded-3 bg-white p-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "d-flex gap-2 align-items-center mb-3";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control";
|
||||
input.value = selected;
|
||||
input.placeholder = "fa-shield-check";
|
||||
input.setAttribute("list", "cms-icon-options");
|
||||
top.appendChild(input);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
|
||||
preview.style.width = "52px";
|
||||
preview.innerHTML = selected
|
||||
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
top.appendChild(preview);
|
||||
wrapper.appendChild(top);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "d-flex flex-wrap gap-2";
|
||||
(schema.options || []).forEach((option) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
|
||||
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
|
||||
button.addEventListener("click", function () {
|
||||
parent[key] = option;
|
||||
input.value = option;
|
||||
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
|
||||
renderAllSections();
|
||||
});
|
||||
grid.appendChild(button);
|
||||
});
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
parent[key] = input.value;
|
||||
preview.innerHTML = input.value
|
||||
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
|
||||
: '<span class="text-muted small">--</span>';
|
||||
});
|
||||
|
||||
col.appendChild(wrapper);
|
||||
appendHelp(col, schema, parent[key]);
|
||||
}
|
||||
|
||||
function renderCheckbox(schema, container, parent, key) {
|
||||
if (typeof parent[key] !== "boolean") {
|
||||
parent[key] = Boolean(parent[key]);
|
||||
}
|
||||
|
||||
const col = createCol(schema.colClass || "col-12");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check mt-4";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "form-check-input";
|
||||
input.checked = parent[key];
|
||||
input.addEventListener("change", function () {
|
||||
parent[key] = input.checked;
|
||||
});
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label fw-semibold";
|
||||
label.textContent = schema.label || key;
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(label);
|
||||
col.appendChild(wrapper);
|
||||
if (schema.helpText) {
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = schema.helpText;
|
||||
col.appendChild(help);
|
||||
}
|
||||
container.appendChild(col);
|
||||
}
|
||||
|
||||
function appendHelp(col, schema, value, extraHint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex justify-content-between gap-3";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "form-text";
|
||||
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
|
||||
wrapper.appendChild(help);
|
||||
|
||||
let counter = null;
|
||||
if (schema.maxLength) {
|
||||
counter = document.createElement("div");
|
||||
counter.className = "form-text text-end ms-auto";
|
||||
updateCounter(counter, String(value || "").length, schema.maxLength);
|
||||
wrapper.appendChild(counter);
|
||||
}
|
||||
|
||||
if (help.textContent || counter) {
|
||||
col.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return counter;
|
||||
}
|
||||
|
||||
function updateCounter(counter, currentLength, maxLength) {
|
||||
if (!counter || !maxLength) return;
|
||||
counter.textContent = `${currentLength}/${maxLength}`;
|
||||
}
|
||||
|
||||
function openImagePicker(imageType, onSuccess) {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
if (!fileInput.files || !fileInput.files[0]) {
|
||||
fileInput.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileInput.files[0]);
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.path) {
|
||||
throw new Error(result.error || "Upload failed");
|
||||
}
|
||||
|
||||
onSuccess(result.path);
|
||||
showToast("Success", "Image uploaded successfully", "success");
|
||||
} catch (error) {
|
||||
showToast("Error", error.message || "Upload failed", "danger");
|
||||
} finally {
|
||||
fileInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function showToast(title, message, type) {
|
||||
const container =
|
||||
document.querySelector(".toast-container") || createToastContainer();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${escapeHtml(
|
||||
title,
|
||||
)}:</strong> ${escapeHtml(message)}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
||||
toast.addEventListener("hidden.bs.toast", function () {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createDefaultValue(schema) {
|
||||
if (!schema) return "";
|
||||
if (schema.type === "primitive") return schema.fieldType === "number" ? 0 : "";
|
||||
if (schema.type === "variant") {
|
||||
return { [schema.discriminator]: schema.options[0].value };
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
const value = {};
|
||||
(schema.fields || []).forEach((field) => {
|
||||
if (field.type === "array") value[field.key] = [];
|
||||
else if (field.type === "object") value[field.key] = createDefaultValue(field);
|
||||
else if (field.type === "checkbox") value[field.key] = false;
|
||||
else if (field.type === "number") value[field.key] = 0;
|
||||
else value[field.key] = field.defaultValue || "";
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function createCol(colClass) {
|
||||
const div = document.createElement("div");
|
||||
div.className = colClass;
|
||||
return div;
|
||||
}
|
||||
|
||||
function inferColClass(type) {
|
||||
if (type === "textarea" || type === "image" || type === "icon") return "col-12";
|
||||
if (type === "checkbox") return "col-12";
|
||||
return "col-md-6";
|
||||
}
|
||||
|
||||
function resolveImageUrl(path) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/")) return `${backendUrl}${path}`;
|
||||
return `${backendUrl}/${path}`;
|
||||
}
|
||||
|
||||
function resolveOptions(schema, root) {
|
||||
if (schema.optionsPath) {
|
||||
const value = getValueByPath(root, schema.optionsPath);
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
return schema.options || [];
|
||||
}
|
||||
|
||||
function collectIcons(schema) {
|
||||
if (!schema) return [];
|
||||
if (schema.type === "icon") return schema.options || [];
|
||||
if (schema.type === "object") return (schema.fields || []).flatMap(collectIcons);
|
||||
if (schema.type === "array") return collectIcons(schema.itemSchema);
|
||||
if (schema.type === "variant") {
|
||||
return Object.values(schema.variants || {}).flatMap((variant) =>
|
||||
collectIcons(variant.schema),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ensureIconDatalist(options) {
|
||||
const existing = document.getElementById("cms-icon-options");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const dataList = document.createElement("datalist");
|
||||
dataList.id = "cms-icon-options";
|
||||
options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option;
|
||||
dataList.appendChild(item);
|
||||
});
|
||||
document.body.appendChild(dataList);
|
||||
}
|
||||
|
||||
function applyAutoSequenceToArray(schema, targetArray) {
|
||||
if (!schema || !Array.isArray(targetArray) || schema.itemSchema.type !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(schema.itemSchema.fields || []).forEach((field) => {
|
||||
if (field.type !== "hidden" || !field.autoSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetArray.forEach((item, index) => {
|
||||
const value = String(index + 1);
|
||||
const padLength = field.autoSequence.padLength || 0;
|
||||
item[field.key] = padLength > 0 ? value.padStart(padLength, "0") : value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayItemTitle(schema, item, index) {
|
||||
const value =
|
||||
item && schema.itemTitleKey && typeof item[schema.itemTitleKey] !== "undefined"
|
||||
? item[schema.itemTitleKey]
|
||||
: null;
|
||||
|
||||
return value || `${schema.itemLabel || "Item"} ${index + 1}`;
|
||||
}
|
||||
|
||||
function getArrayItemSubtitle(schema, item) {
|
||||
if (!item || !schema.itemSubtitleKey) return "";
|
||||
return item[schema.itemSubtitleKey] || "";
|
||||
}
|
||||
|
||||
function renderItemActions(actions, item) {
|
||||
if (!Array.isArray(actions) || !actions.length || !item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const href = fillTemplate(action.hrefTemplate, item);
|
||||
if (!href) return "";
|
||||
return `<button type="button" class="${escapeHtml(
|
||||
action.className || "btn btn-outline-primary btn-sm",
|
||||
)}" data-item-href="${escapeHtml(href)}">${
|
||||
action.icon ? `<i class="${escapeHtml(action.icon)} me-1"></i>` : ""
|
||||
}${escapeHtml(action.label || "Open")}</button>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function passesVisibility(condition, parent, context) {
|
||||
if (!condition || !condition.path) return true;
|
||||
const target =
|
||||
condition.path === "$item"
|
||||
? context.item
|
||||
: getValueByPath(parent, condition.path) ??
|
||||
getValueByPath(context.item, condition.path) ??
|
||||
getValueByPath(context.root, condition.path);
|
||||
|
||||
if (Array.isArray(condition.equals)) {
|
||||
return condition.equals.includes(target);
|
||||
}
|
||||
|
||||
return target === condition.equals;
|
||||
}
|
||||
|
||||
function appendPath(basePath, segment) {
|
||||
return basePath ? `${basePath}.${segment}` : segment;
|
||||
}
|
||||
|
||||
function getValueByPath(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
return String(path)
|
||||
.split(".")
|
||||
.reduce((current, segment) => {
|
||||
if (current === null || typeof current === "undefined") return undefined;
|
||||
return current[segment];
|
||||
}, target);
|
||||
}
|
||||
|
||||
function fillTemplate(template, item) {
|
||||
if (!template) return "";
|
||||
return template.replace(/\{([^}]+)\}/g, function (_, key) {
|
||||
return item[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'sections' ? 'show active' : '' %>" id="sections" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-file-lines me-2"></i>Sections</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="sections"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="tab-pane fade <%= activeTab === 'sidebar' ? 'show active' : '' %>" id="sidebar" role="tabpanel">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0"><i class="fas fa-bars me-2"></i>Sidebar</h6>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="page-editor-section" data-section-key="sidebar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
|
||||
<p class="text-muted mb-0"><%= subtitle %></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="fas fa-external-link-alt me-2"></i>View Page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="cmsEditorForm" class="content-with-fixed-buttons" novalidate>
|
||||
<input type="hidden" name="pageJson" id="pageJson" />
|
||||
<input type="hidden" name="activeTab" id="activeTabInput" value="<%= activeTab %>" />
|
||||
|
||||
<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 <%= activeTab === 'sections' ? 'active' : '' %>" data-bs-toggle="tab" href="#sections" role="tab" data-tab-key="sections">
|
||||
<i class="fas fa-file-lines me-2"></i>Sections
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<%- include("partials/sections-tab", { activeTab }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed-bottom-buttons">
|
||||
<button type="reset" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
|
||||
window.pageEditorData = <%- JSON.stringify(data) %>;
|
||||
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
|
||||
</script>
|
||||
<%- include("partials/sections-editor-script") %>
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user