refactor(policies): migrate to block-based content editor

Replace the legacy section-based policy editor with a flexible block-based system. This transition enables real-time previews, a wider variety of content types (headings, quotes, callouts), and improved data normalization.
- Implement `policies-block-editor.js` for dynamic content management
- Add `normalizePoliciesDocument` and `validateContent` utilities to handle data migration and integrity
- Update `policiesController.js` to support the new block structure and validation logic
- Migrate `policies.json` from `sections` array to a `content.blocks` structure
- Remove obsolete section editor views and partials
- Update `policiesConfig.js` to reflect the new editor capabilities
This commit is contained in:
Tống Thành Đạt
2026-04-22 10:14:54 +07:00
parent f26b072954
commit cdaddddfeb
10 changed files with 2438 additions and 1265 deletions
+52 -10
View File
@@ -10,6 +10,11 @@ const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const jsonHelper = require("../utils/jsonHelper");
const { ensureUniqueIds } = require("../utils/contentEditorIds");
const {
normalizePolicy,
normalizePoliciesDocument,
validateContent,
} = require("../utils/policiesBlockContent");
function formatLastUpdated(date = new Date()) {
return `Last updated: ${new Intl.DateTimeFormat("en-US", {
@@ -29,10 +34,11 @@ function withLastUpdated(payload, { beforeData } = {}) {
"policy",
).map((policy) => {
const existingPolicy = existingPolicies.find((item) => item.id === policy.id);
const normalizedExistingPolicy = normalizePolicy(existingPolicy || policy);
return {
...policy,
sections: Array.isArray(existingPolicy?.sections) ? existingPolicy.sections : [],
content: normalizedExistingPolicy.content,
};
});
@@ -51,6 +57,8 @@ const baseController = createPageContentController({
modelName: "PoliciesPage",
auditAction: AUDIT_ACTIONS.UPDATE_POLICIES,
editorConfig: policiesConfig,
normalizeForEditor: normalizePoliciesDocument,
normalizeForApi: normalizePoliciesDocument,
preparePayload: withLastUpdated,
});
@@ -73,7 +81,7 @@ module.exports = {
async editSections(req, res) {
try {
const doc = await PoliciesPage.getSingle();
const data = doc.toObject();
const data = normalizePoliciesDocument(doc.toObject());
const policy = (data.policies || []).find(
(item) => item.id === req.params.policyId,
);
@@ -95,7 +103,10 @@ module.exports = {
layout: "layouts/main",
title: editorConfig.title,
subtitle: editorConfig.subtitle,
data: { sections: policy.sections || [] },
data: {
policy,
content: policy.content,
},
editorConfig,
activeTab: "sections",
frontendUrl,
@@ -128,9 +139,31 @@ module.exports = {
return req.session.save(() => res.redirect("/admin/policies"));
}
doc.policies[policyIndex].sections = Array.isArray(payload.sections)
? payload.sections
: [];
const policyIds = (doc.policies || [])
.map((item) => item.id)
.filter(Boolean);
const validation = validateContent(payload.content, policyIds);
if (validation.errors.length > 0) {
req.flash("error_msg", validation.errors.join(" "));
return req.session.save(() =>
res.redirect(`/admin/policies/${req.params.policyId}/section`),
);
}
const normalizedPolicies = (doc.policies || []).map((item) =>
normalizePolicy(JSON.parse(JSON.stringify(item))),
);
const currentPolicy = normalizedPolicies[policyIndex];
const updatedPolicy = {
...currentPolicy,
content: validation.content,
};
delete updatedPolicy.sections;
delete updatedPolicy.contentByLanguage;
normalizedPolicies.splice(policyIndex, 1, updatedPolicy);
doc.policies = normalizedPolicies;
doc.hero = {
...(doc.hero || {}),
lastUpdated: formatLastUpdated(),
@@ -154,14 +187,23 @@ module.exports = {
});
}
const finalData = await PoliciesPage.findOne()
const finalData = normalizePoliciesDocument(
(await PoliciesPage.findOne()
.select("-_id -__v -createdAt -updatedAt")
.lean();
.lean()) || {},
);
jsonHelper.writeJsonFile(policiesConfig.dataFile, finalData);
req.flash("success_msg", "Policy sections updated successfully");
const successMessage = validation.warnings.length
? `Policy content updated with warnings: ${validation.warnings.join(" ")}`
: "Policy content updated successfully";
req.flash("success_msg", successMessage);
const redirectUrl =
req.body.intent === "save-back"
? "/admin/policies?tab=policies"
: `/admin/policies/${req.params.policyId}/section`;
return req.session.save(() =>
res.redirect(`/admin/policies/${req.params.policyId}/section`),
res.redirect(redirectUrl),
);
} catch (error) {
console.error("policies section update error:", error);
+220 -133
View File
@@ -20,53 +20,101 @@
"title": "Privacy Policy",
"effectiveDate": "Effective Date: September 15, 2025",
"intro": "At LAMS, we are committed to protecting your privacy and ensuring the security of your personal information. This Privacy Policy outlines how we collect, use, and safeguard the data of our students, applicants, and website visitors.",
"sections": [
{
"type": "list",
"heading": "1. Information We Collect",
"intro": "We collect information that you provide directly to us when you apply for admission, enroll in courses, request information, or contact our support teams. This may include:",
"items": [
"Personal identification information such as name, address, email address, phone number, date of birth, and government-issued ID numbers where required.",
"Academic records such as transcripts, previous educational history, standardized test scores, and current academic performance data.",
"Financial information such as payment details, financial aid applications, and billing history."
]
},
{
"type": "list",
"heading": "2. How We Use Your Information",
"intro": "Your information is primarily used to provide educational services and manage your student journey. Specific uses include:",
"items": [
"Processing admissions applications and enrollment.",
"Delivering course materials, grades, and academic advising.",
"Processing tuition payments and administering financial aid.",
"Communicating important university updates and policy changes."
]
},
{
"type": "text",
"heading": "3. Data Sharing and Third Parties",
"paragraphs": [
{
"text": "We do not sell your personal information. We may share your data with trusted third-party service providers who assist us in operating our university, including learning management systems and payment processors. These partners are bound by strict confidentiality agreements. For more details, refer to our Vendor Data Processing Addendum.",
"links": [
{
"label": "Vendor Data Processing Addendum",
"href": "#"
}
]
},
{
"text": "If you have questions about this policy, please contact our Data Protection Officer at privacy@LAMS.edu.",
"links": [
{
"label": "privacy@LAMS.edu",
"href": "mailto:privacy@LAMS.edu"
}
]
}
]
}
]
"content": {
"blocks": [
{
"id": "1-information-we-collect-qa-20260422-041603",
"type": "heading",
"level": 2,
"html": "<span>1. Information We Collect QA 20260422 041603</span>"
},
{
"id": "1-information-we-collect-qa-20260422-041603-intro",
"type": "paragraph",
"html": "<p><b>We collect information directly from students and applicants. Validation marker QA 20260422 041603.</b></p>"
},
{
"id": "block-1776821411652-j34gz",
"type": "quote",
"html": "<p>Quote QA 20260422 0127</p>",
"caption": "QA Source"
},
{
"id": "1-information-we-collect-qa-20260422-041603-2",
"type": "list",
"style": "unordered",
"items": [
{
"id": "personal-identification-information-such-as-name-address-email-address-phone-number-date-of-birth-and-government-issued-id-numbers-where-required",
"html": "<p>Personal identification information such as name, address, email address, phone number, date of birth, and government-issued ID numbers where required.</p>"
},
{
"id": "financial-information-such-as-payment-details-financial-aid-applications-and-billing-history",
"html": "<p>Financial information such as payment details, financial aid applications, and billing history.</p>"
},
{
"id": "audit-ready-retention-notice-qa-20260422-041603",
"html": "<p>Audit-ready retention notice QA 20260422 041603</p>"
}
]
},
{
"id": "3-data-sharing-and-third-parties",
"type": "heading",
"level": 2,
"html": "<span>3. Data Sharing and Third Parties</span>"
},
{
"id": "we-do-not-sell-your-personal-information-we-may-share-your-data-with-trusted-third-party-service-providers-who-assist-us-in-operating-our-university-including-learning-management-systems-and-payment-processors-these-partners-are-bound-by-strict-confidentiality-agreements-for-more-details-refer-to-our-vendor-data-processing-addendum",
"type": "paragraph",
"html": "<p>We do not sell your personal information. We may share your data with trusted third-party service providers who assist us in operating our university, including learning management systems and payment processors. These partners are bound by strict confidentiality agreements. For more details, refer to our Vendor Data Processing Addendum.</p>"
},
{
"id": "if-you-have-questions-about-this-policy-please-contact-our-data-protection-officer-at-privacy-lams-edu",
"type": "paragraph",
"html": "<p>If you have questions about this policy, please contact our Data Protection Officer at privacy@LAMS.edu.</p>"
},
{
"id": "for-policy-navigation-see-terms-of-use-qa-20260422-041603",
"type": "paragraph",
"html": "<p>For policy navigation, see Terms of Use QA 20260422 041603.</p>"
},
{
"id": "2-how-we-use-your-information",
"type": "heading",
"level": 2,
"html": "<span>2. How We Use Your Information</span>"
},
{
"id": "2-how-we-use-your-information-intro",
"type": "paragraph",
"html": "<p>Your information is primarily used to provide educational services and manage your student journey. Specific uses include:</p>"
},
{
"id": "2-how-we-use-your-information-2",
"type": "list",
"style": "unordered",
"items": [
{
"id": "processing-admissions-applications-and-enrollment",
"html": "<p>Processing admissions applications and enrollment.</p>"
},
{
"id": "delivering-course-materials-grades-and-academic-advising",
"html": "<p>Delivering course materials, grades, and academic advising.</p>"
},
{
"id": "processing-tuition-payments-and-administering-financial-aid",
"html": "<p>Processing tuition payments and administering financial aid.</p>"
},
{
"id": "communicating-important-university-updates-and-policy-changes",
"html": "<p>Communicating important university updates and policy changes.</p>"
}
]
}
]
}
},
{
"id": "terms",
@@ -74,45 +122,48 @@
"title": "Terms of Use",
"effectiveDate": "Effective Date: January 1, 2025",
"intro": "Welcome to LAMS. By accessing our website, student portal, or utilizing our educational services, you agree to be bound by these Terms of Use and our Privacy Policy.",
"sections": [
{
"type": "text",
"heading": "1. Academic Integrity",
"paragraphs": [
{
"text": "As a student of LAMS, you are expected to uphold the highest standards of academic honesty. Plagiarism, cheating, and the unauthorized sharing of course materials are strictly prohibited and may result in disciplinary action."
}
]
},
{
"type": "text",
"heading": "2. Account Security",
"paragraphs": [
{
"text": "You are responsible for maintaining the confidentiality of your student portal credentials. You must immediately notify the IT Helpdesk of any unauthorized use of your account."
}
]
},
{
"type": "cards",
"cards": [
{
"icon": "fa-book-open",
"title": "Course Materials",
"description": "All course content provided via the learning management system is the intellectual property of LAMS or its licensors. It is for personal educational use only."
},
{
"icon": "fa-credit-card",
"title": "Subscription Terms",
"description": "Monthly subscriptions automatically renew unless canceled prior to the billing cycle. See the Financial Policies for refund criteria.",
"link": {
"label": "Financial Policies",
"href": "#"
}
}
]
}
]
"content": {
"blocks": [
{
"id": "1-academic-integrity",
"type": "heading",
"level": 2,
"html": "<span>1. Academic Integrity</span>"
},
{
"id": "as-a-student-of-lams-you-are-expected-to-uphold-the-highest-standards-of-academic-honesty-plagiarism-cheating-and-the-unauthorized-sharing-of-course-materials-are-strictly-prohibited-and-may-result-in-disciplinary-action",
"type": "paragraph",
"html": "<p>As a student of LAMS, you are expected to uphold the highest standards of academic honesty. Plagiarism, cheating, and the unauthorized sharing of course materials are strictly prohibited and may result in disciplinary action.</p>"
},
{
"id": "2-account-security",
"type": "heading",
"level": 2,
"html": "<span>2. Account Security</span>"
},
{
"id": "you-are-responsible-for-maintaining-the-confidentiality-of-your-student-portal-credentials-you-must-immediately-notify-the-it-helpdesk-of-any-unauthorized-use-of-your-account",
"type": "paragraph",
"html": "<p>You are responsible for maintaining the confidentiality of your student portal credentials. You must immediately notify the IT Helpdesk of any unauthorized use of your account.</p>"
},
{
"id": "course-materials",
"type": "callout",
"tone": "info",
"title": "Course Materials",
"icon": "fa-book-open",
"html": "<p>All course content provided via the learning management system is the intellectual property of LAMS or its licensors. It is for personal educational use only.</p>"
},
{
"id": "subscription-terms",
"type": "callout",
"tone": "info",
"title": "Subscription Terms",
"icon": "fa-credit-card",
"html": "<p>Monthly subscriptions automatically renew unless canceled prior to the billing cycle. See the Financial Policies for refund criteria.</p><p><a href=\"#\">Financial Policies</a></p>"
}
]
}
},
{
"id": "accessibility",
@@ -120,32 +171,46 @@
"title": "Accessibility Statement",
"effectiveDate": "Effective Date: September 15, 2025",
"intro": "LAMS is committed to providing digital learning experiences that are accessible to all students, applicants, faculty, and visitors.",
"sections": [
{
"type": "list",
"heading": "1. Our Accessibility Commitments",
"items": [
"We design learning materials and digital services with accessibility in mind.",
"We review core student journeys for keyboard access, screen reader support, and readable contrast.",
"We provide reasonable accommodations through our student support and advising teams."
]
},
{
"type": "text",
"heading": "2. Requesting Support",
"paragraphs": [
{
"text": "If you encounter an accessibility barrier, contact our support team so we can review the issue and provide an appropriate path forward.",
"links": [
{
"label": "contact our support team",
"href": "/contact"
}
]
}
]
}
]
"content": {
"blocks": [
{
"id": "1-our-accessibility-commitments",
"type": "heading",
"level": 2,
"html": "<span>1. Our Accessibility Commitments</span>"
},
{
"id": "1-our-accessibility-commitments-2",
"type": "list",
"style": "unordered",
"items": [
{
"id": "we-design-learning-materials-and-digital-services-with-accessibility-in-mind",
"html": "<p>We design learning materials and digital services with accessibility in mind.</p>"
},
{
"id": "we-review-core-student-journeys-for-keyboard-access-screen-reader-support-and-readable-contrast",
"html": "<p>We review core student journeys for keyboard access, screen reader support, and readable contrast.</p>"
},
{
"id": "we-provide-reasonable-accommodations-through-our-student-support-and-advising-teams",
"html": "<p>We provide reasonable accommodations through our student support and advising teams.</p>"
}
]
},
{
"id": "2-requesting-support",
"type": "heading",
"level": 2,
"html": "<span>2. Requesting Support</span>"
},
{
"id": "if-you-encounter-an-accessibility-barrier-contact-our-support-team-so-we-can-review-the-issue-and-provide-an-appropriate-path-forward",
"type": "paragraph",
"html": "<p>If you encounter an accessibility barrier, <a href=\"/contact\">contact our support team</a> so we can review the issue and provide an appropriate path forward.</p>"
}
]
}
},
{
"id": "cookies",
@@ -153,26 +218,46 @@
"title": "Cookie Preferences",
"effectiveDate": "Effective Date: September 15, 2025",
"intro": "We use cookies and similar technologies to operate our website, understand usage patterns, and improve the student experience.",
"sections": [
{
"type": "list",
"heading": "1. Cookie Categories",
"items": [
"Essential cookies keep core services such as authentication and security running.",
"Analytics cookies help us understand aggregate site usage and improve content.",
"Preference cookies remember non-sensitive choices such as language and display settings."
]
},
{
"type": "text",
"heading": "2. Managing Preferences",
"paragraphs": [
{
"text": "You can manage cookies through your browser settings. Some essential cookies cannot be disabled because they are required for secure access to student services."
}
]
}
]
"content": {
"blocks": [
{
"id": "1-cookie-categories",
"type": "heading",
"level": 2,
"html": "<span>1. Cookie Categories</span>"
},
{
"id": "1-cookie-categories-2",
"type": "list",
"style": "unordered",
"items": [
{
"id": "essential-cookies-keep-core-services-such-as-authentication-and-security-running",
"html": "<p>Essential cookies keep core services such as authentication and security running.</p>"
},
{
"id": "analytics-cookies-help-us-understand-aggregate-site-usage-and-improve-content",
"html": "<p>Analytics cookies help us understand aggregate site usage and improve content.</p>"
},
{
"id": "preference-cookies-remember-non-sensitive-choices-such-as-language-and-display-settings",
"html": "<p>Preference cookies remember non-sensitive choices such as language and display settings.</p>"
}
]
},
{
"id": "2-managing-preferences",
"type": "heading",
"level": 2,
"html": "<span>2. Managing Preferences</span>"
},
{
"id": "you-can-manage-cookies-through-your-browser-settings-some-essential-cookies-cannot-be-disabled-because-they-are-required-for-secure-access-to-student-services",
"type": "paragraph",
"html": "<p>You can manage cookies through your browser settings. Some essential cookies cannot be disabled because they are required for secure access to student services.</p>"
}
]
}
},
{
"navLabel": "hehehe",
@@ -180,7 +265,9 @@
"effectiveDate": "hehehe",
"intro": "hehehe",
"id": "hehehe",
"sections": []
"content": {
"blocks": []
}
}
]
}
File diff suppressed because it is too large Load Diff
+7 -107
View File
@@ -13,7 +13,7 @@ const {
const baseConfig = {
key: "policies",
title: "Policies Management",
subtitle: "Manage the content for the policies page",
subtitle: "Manage policy metadata and block-based content for the policies page",
routeBase: "/admin/policies",
apiPath: "/api/policies",
previewPath: "/policies",
@@ -69,7 +69,7 @@ const baseConfig = {
emptyText: "No policies yet.",
itemActions: [
{
label: "Edit Sections",
label: "Edit Content",
icon: "fas fa-pen-to-square",
hrefTemplate: "/admin/policies/{id}/section",
className: "btn btn-outline-primary btn-sm",
@@ -90,114 +90,14 @@ function createPoliciesSectionEditorConfig(policy, allPolicies = []) {
}));
return {
key: "policySections",
title: `Policy Sections: ${policy.title || policy.id}`,
subtitle: "Manage section content and paragraph order for this policy",
key: "policyContent",
title: `Policy Content: ${policy.title || policy.id}`,
subtitle: "Edit block-based policy content with realtime preview",
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 open when this link is clicked.",
}),
],
{
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.",
},
),
},
],
policyId: policy.id,
policyOptions,
};
}
+404
View File
@@ -0,0 +1,404 @@
const { ensureUniqueIds, slugifyContentId } = require("./contentEditorIds");
const BLOCK_TYPES = new Set([
"heading",
"paragraph",
"list",
"quote",
"divider",
"callout",
]);
function createBlockId(prefix, value, index) {
return slugifyContentId(value, `${prefix}-${index + 1}`);
}
function stripHtml(html = "") {
return String(html || "")
.replace(/<br\s*\/?>/gi, " ")
.replace(/<\/p>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function escapeHtml(value = "") {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function ensureParagraphHtml(value = "") {
const raw = String(value || "").trim();
if (!raw) {
return "";
}
if (/<[a-z][\s\S]*>/i.test(raw)) {
return raw;
}
return `<p>${escapeHtml(raw)}</p>`;
}
function createInternalAnchor(policyId, label) {
return `<a href="#${escapeHtml(policyId)}" data-policy-id="${escapeHtml(
policyId,
)}" data-link-kind="internal">${escapeHtml(label)}</a>`;
}
function createExternalAnchor(href, label) {
return `<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
}
function paragraphToHtml(paragraph = {}) {
const text = String(paragraph.text || "");
const links = Array.isArray(paragraph.links) ? paragraph.links : [];
let html = escapeHtml(text);
links.forEach((link, index) => {
const label = String(link.label || "").trim();
if (!label) {
return;
}
const anchor = link.tabId
? createInternalAnchor(link.tabId, label)
: link.href
? createExternalAnchor(link.href, label)
: escapeHtml(label);
if (html.includes(escapeHtml(label))) {
html = html.replace(escapeHtml(label), anchor);
return;
}
const suffix = index === 0 ? "" : " ";
html = `${html}${suffix}${anchor}`;
});
return `<p>${html}</p>`;
}
function legacySectionToBlocks(section = {}, sectionIndex = 0) {
const blocks = [];
const heading = String(section.heading || section.title || "").trim();
if (heading) {
blocks.push({
id: createBlockId("heading", heading, sectionIndex),
type: "heading",
level: 2,
html: `<span>${escapeHtml(heading)}</span>`,
});
}
if (section.type === "text") {
const paragraphs = Array.isArray(section.paragraphs) ? section.paragraphs : [];
paragraphs.forEach((paragraph, paragraphIndex) => {
blocks.push({
id: createBlockId(
"paragraph",
paragraph.text || `${heading || "paragraph"}-${paragraphIndex + 1}`,
paragraphIndex,
),
type: "paragraph",
html: paragraphToHtml(paragraph),
});
});
return blocks;
}
if (section.type === "list") {
if (section.intro) {
blocks.push({
id: createBlockId("paragraph", `${heading || "list"}-intro`, sectionIndex),
type: "paragraph",
html: ensureParagraphHtml(section.intro),
});
}
blocks.push({
id: createBlockId("list", heading || `list-${sectionIndex + 1}`, sectionIndex),
type: "list",
style: "unordered",
items: (Array.isArray(section.items) ? section.items : []).map((item, itemIndex) => ({
id: createBlockId("item", item || `item-${itemIndex + 1}`, itemIndex),
html: ensureParagraphHtml(item),
})),
});
return blocks;
}
if (section.type === "cards") {
const cards = Array.isArray(section.cards) ? section.cards : [];
cards.forEach((card, cardIndex) => {
const description = String(card.description || "");
const linkHtml = card.link?.tabId
? `<p>${createInternalAnchor(card.link.tabId, card.link.label || "Open")}</p>`
: card.link?.href
? `<p>${createExternalAnchor(card.link.href, card.link.label || card.link.href)}</p>`
: "";
blocks.push({
id: createBlockId("callout", card.title || `card-${cardIndex + 1}`, cardIndex),
type: "callout",
tone: "info",
title: String(card.title || ""),
icon: String(card.icon || ""),
html: `${ensureParagraphHtml(description)}${linkHtml}`,
});
});
}
return blocks;
}
function legacySectionsToBlocks(sections = []) {
return ensureUniqueIds(
(Array.isArray(sections) ? sections : []).flatMap(legacySectionToBlocks),
(block) => block.id,
(block, index) => stripHtml(block.html) || `${block.type}-${index + 1}`,
"block",
);
}
function normalizeListItems(items = []) {
return ensureUniqueIds(
(Array.isArray(items) ? items : [])
.map((item, index) => {
if (typeof item === "string") {
return {
id: createBlockId("item", item, index),
html: ensureParagraphHtml(item),
};
}
return {
id: item.id || createBlockId("item", stripHtml(item.html), index),
html: ensureParagraphHtml(item.html),
};
})
.filter((item) => item.html),
(item) => item.id,
(item, index) => stripHtml(item.html) || `item-${index + 1}`,
"item",
);
}
function normalizeBlock(block = {}, index = 0) {
const type = BLOCK_TYPES.has(block.type) ? block.type : "paragraph";
const normalized = {
id:
block.id ||
createBlockId(type, block.title || stripHtml(block.html) || type, index),
type,
};
if (type === "heading") {
normalized.level = [1, 2, 3].includes(Number(block.level))
? Number(block.level)
: 2;
normalized.html = ensureParagraphHtml(block.html || block.content || block.title || "");
return normalized;
}
if (type === "paragraph" || type === "quote") {
normalized.html = ensureParagraphHtml(block.html || block.content || "");
if (type === "quote") {
normalized.caption = String(block.caption || "");
}
return normalized;
}
if (type === "list") {
normalized.style = block.style === "ordered" ? "ordered" : "unordered";
normalized.items = normalizeListItems(block.items);
return normalized;
}
if (type === "divider") {
return normalized;
}
normalized.tone = ["info", "warning", "success"].includes(block.tone)
? block.tone
: "info";
normalized.title = String(block.title || "");
normalized.icon = String(block.icon || "");
normalized.html = ensureParagraphHtml(block.html || block.content || "");
return normalized;
}
function normalizeContent(content = {}) {
const blocks = ensureUniqueIds(
(Array.isArray(content.blocks) ? content.blocks : []).map(normalizeBlock),
(block) => block.id,
(block, index) => stripHtml(block.title || block.html) || `${block.type}-${index + 1}`,
"block",
);
return {
blocks,
};
}
function createEmptyContent() {
return {
blocks: [],
};
}
function normalizePolicyContent(policy = {}) {
if (policy.content && typeof policy.content === "object") {
return normalizeContent(policy.content);
}
if (policy.contentByLanguage && typeof policy.contentByLanguage === "object") {
const englishContent = policy.contentByLanguage.en || policy.contentByLanguage.vi;
return normalizeContent(englishContent || createEmptyContent());
}
const migratedBlocks = legacySectionsToBlocks(policy.sections);
return normalizeContent({ blocks: migratedBlocks });
}
function normalizePolicy(policy = {}) {
const normalized = {
...policy,
content: normalizePolicyContent(policy),
};
delete normalized.sections;
delete normalized.contentByLanguage;
return normalized;
}
function normalizePoliciesDocument(data = {}) {
return {
...data,
policies: (Array.isArray(data.policies) ? data.policies : []).map(normalizePolicy),
};
}
function parseAnchorAttributes(source = "") {
const attributes = {};
const regex = /([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
let match = regex.exec(source);
while (match) {
attributes[match[1]] = match[3] || match[4] || "";
match = regex.exec(source);
}
return attributes;
}
function collectLinkErrors(html = "", validPolicyIds = []) {
const errors = [];
const anchorRegex = /<a\b([^>]*)>/gi;
let match = anchorRegex.exec(String(html || ""));
while (match) {
const attrs = parseAnchorAttributes(match[1]);
const href = String(attrs.href || "").trim();
const policyId = String(attrs["data-policy-id"] || "").trim();
const isInternal = String(attrs["data-link-kind"] || "") === "internal";
if (isInternal) {
if (!policyId || !validPolicyIds.includes(policyId)) {
errors.push("Internal link points to an invalid policy.");
}
} else if (
href &&
!/^(https?:\/\/|mailto:|tel:|\/|#)/i.test(href)
) {
errors.push(`Invalid link URL "${href}".`);
}
if (!isInternal && !href) {
errors.push("Link is missing a URL.");
}
match = anchorRegex.exec(String(html || ""));
}
return errors;
}
function validateBlocks(blocks = [], validPolicyIds = []) {
const errors = [];
const warnings = [];
let h1Count = 0;
blocks.forEach((block, index) => {
const label = `Block ${index + 1}`;
if (block.type === "heading" && Number(block.level) === 1) {
h1Count += 1;
}
if (block.type === "divider") {
return;
}
if (block.type === "list") {
if (!Array.isArray(block.items) || block.items.length === 0) {
warnings.push(`${label} has no list items.`);
}
block.items.forEach((item, itemIndex) => {
if (!stripHtml(item.html)) {
warnings.push(`${label} item ${itemIndex + 1} is empty.`);
}
});
errors.push(...collectLinkErrors(JSON.stringify(block.items), validPolicyIds));
return;
}
if (block.type === "callout" && !stripHtml(block.html) && !stripHtml(block.title)) {
warnings.push(`${label} is empty.`);
} else if (!stripHtml(block.html) && block.type !== "divider") {
warnings.push(`${label} is empty.`);
}
errors.push(...collectLinkErrors(block.html, validPolicyIds));
});
if (h1Count > 1) {
errors.push(
`Content has ${h1Count} H1 blocks. Only one H1 is allowed.`,
);
}
return { errors, warnings };
}
function validateContent(content = {}, policyIds = []) {
const normalized = normalizeContent(content || createEmptyContent());
const { errors, warnings } = validateBlocks(normalized.blocks, policyIds);
if (normalized.blocks.length === 0) {
warnings.push("Content is empty.");
}
return {
content: normalized,
errors: Array.from(new Set(errors)),
warnings: Array.from(new Set(warnings)),
};
}
module.exports = {
createEmptyContent,
normalizePolicy,
normalizePoliciesDocument,
normalizePolicyContent,
validateContent,
stripHtml,
};
+3 -5
View File
@@ -52,11 +52,9 @@
</div>
</div>
<script>
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
window.pageEditorData = <%- JSON.stringify(data) %>;
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
</script>
<script type="application/json" id="pageEditorConfigData"><%- JSON.stringify(editorConfig) %></script>
<script type="application/json" id="pageEditorDataPayload"><%- JSON.stringify(data) %></script>
<script type="application/json" id="pageEditorBackendUrlData"><%- JSON.stringify(backendUrl) %></script>
<%- include("partials/editor-script") %>
@@ -1,8 +1,8 @@
<script>
(function () {
const config = window.pageEditorConfig;
const initialData = window.pageEditorData;
const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, "");
const config = readJsonScript("pageEditorConfigData");
const initialData = readJsonScript("pageEditorDataPayload");
const backendUrl = String(readJsonScript("pageEditorBackendUrlData") || "").replace(/\/$/, "");
const form = document.getElementById("cmsEditorForm");
const pageJsonInput = document.getElementById("pageJson");
const activeTabInput = document.getElementById("activeTabInput");
@@ -52,6 +52,17 @@
config.tabs.forEach((tab) => renderSection(tab.key));
}
function readJsonScript(id) {
const node = document.getElementById(id);
if (!node) return null;
try {
return JSON.parse(node.textContent || "null");
} catch (error) {
console.error(`Failed to parse JSON script "${id}"`, error);
return null;
}
}
function updateTabUrl(tabKey) {
const url = new URL(window.location.href);
url.searchParams.set("tab", tabKey);
@@ -1,947 +0,0 @@
<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 = "cms-editor-group";
const header = document.createElement("div");
header.className = "mb-3";
header.innerHTML = `
<div class="mb-2">
<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>
`;
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 cms-item-card mb-3";
itemCard.dataset.index = String(index);
const itemHeader = document.createElement("div");
itemHeader.className = "card-header 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="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">
<button type="button" class="cms-collapse-toggle" data-toggle-item="true" title="Collapse section">
<i class="fas fa-chevron-down"></i>
</button>
${renderItemActions(schema.itemActions, item)}
<button type="button" class="cms-remove-button" data-remove-item="true" title="Remove item">
<i class="fas fa-trash-alt"></i>
</button>
</div>
`;
itemHeader
.querySelector('[data-toggle-item="true"]')
.addEventListener("click", function () {
itemCard.classList.toggle("is-collapsed");
});
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);
});
const addButton = document.createElement("button");
addButton.type = "button";
addButton.className = "cms-add-button mt-2";
addButton.innerHTML = `<i class="fas fa-plus me-2"></i>${escapeHtml(schema.addLabel || `Add ${schema.itemLabel || "Item"}`)}`;
addButton.addEventListener("click", function () {
parent[key].push(createDefaultValue(schema.itemSchema));
applyAutoSequenceToArray(schema, parent[key]);
renderSection(tabKey);
});
card.appendChild(addButton);
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 = "cms-icon-combobox";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "cms-icon-dropdown-trigger";
const triggerValue = document.createElement("div");
triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
preview.className = "cms-icon-preview";
triggerValue.appendChild(preview);
const triggerText = document.createElement("div");
triggerText.className = "cms-icon-dropdown-text";
triggerValue.appendChild(triggerText);
trigger.appendChild(triggerValue);
const caret = document.createElement("i");
caret.className = "fas fa-chevron-down cms-icon-dropdown-caret";
trigger.appendChild(caret);
wrapper.appendChild(trigger);
const panel = document.createElement("div");
panel.className = "cms-icon-dropdown-panel d-none";
const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.className = "form-control";
searchInput.placeholder = "Search icon name";
panel.appendChild(searchInput);
const optionsWrap = document.createElement("div");
optionsWrap.className = "cms-icon-options";
panel.appendChild(optionsWrap);
const empty = document.createElement("div");
empty.className = "cms-icon-option-empty d-none";
empty.textContent = "No matching icons";
panel.appendChild(empty);
wrapper.appendChild(panel);
const setOpen = function (isOpen) {
wrapper.classList.toggle("is-open", isOpen);
panel.classList.toggle("d-none", !isOpen);
if (isOpen) {
searchInput.focus();
searchInput.select();
}
};
const updateTrigger = function (value) {
preview.innerHTML = value
? `<i class="fa-solid ${escapeHtml(value)}"></i>`
: '<span class="text-muted small">--</span>';
triggerText.innerHTML = value
? `<strong>${escapeHtml(value)}</strong><span class="text-muted small">Selected icon</span>`
: '<strong>Select icon</strong><span class="text-muted small">No icon selected</span>';
};
const renderOptions = function (searchTerm = "") {
const normalized = String(searchTerm || "").trim().toLowerCase();
const filteredOptions = (schema.options || []).filter((option) =>
option.toLowerCase().includes(normalized),
);
optionsWrap.innerHTML = "";
empty.classList.toggle("d-none", filteredOptions.length > 0);
filteredOptions.forEach((option) => {
const button = document.createElement("button");
button.type = "button";
button.className = `cms-icon-option ${parent[key] === option ? "is-active" : ""}`;
button.innerHTML = `<span class="cms-icon-option-main"><i class="fa-solid ${escapeHtml(option)}"></i><span>${escapeHtml(option)}</span></span>${parent[key] === option ? '<i class="fas fa-check small"></i>' : ""}`;
button.addEventListener("click", function () {
parent[key] = option;
searchInput.value = option;
updateTrigger(option);
renderOptions(option);
setOpen(false);
});
optionsWrap.appendChild(button);
});
};
trigger.addEventListener("click", function () {
const nextOpen = panel.classList.contains("d-none");
setOpen(nextOpen);
if (nextOpen) {
renderOptions(searchInput.value || parent[key] || "");
}
});
searchInput.addEventListener("input", function () {
renderOptions(searchInput.value);
});
wrapper.addEventListener("focusout", function () {
window.setTimeout(function () {
if (!wrapper.contains(document.activeElement)) {
setOpen(false);
}
}, 0);
});
searchInput.value = selected;
updateTrigger(selected);
renderOptions(selected);
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 = "field-meta-row";
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 = "field-char-count";
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
})();
</script>
@@ -1,13 +0,0 @@
<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>
+646 -47
View File
@@ -1,60 +1,659 @@
<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>
<style>
#policyBlockEditor.container {
max-width: 85%;
}
<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 %>" />
.policy-block-editor {
--editor-border: rgba(148, 163, 184, 0.22);
--editor-muted: #64748b;
--editor-bg: #f8fafc;
--editor-surface: #ffffff;
--editor-soft: #eef2ff;
--editor-danger: #dc2626;
--editor-warning: #d97706;
--editor-success: #15803d;
--editor-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
padding-bottom: 140px;
}
<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>
.policy-block-editor .editor-shell {
display: grid;
grid-template-columns: minmax(0, 1.18fr) minmax(320px, 0.82fr);
gap: 1.5rem;
align-items: start;
}
<div class="tab-content">
<%- include("partials/sections-tab", { activeTab }) %>
.policy-block-editor .editor-panel,
.policy-block-editor .preview-panel {
border: 1px solid var(--editor-border);
border-radius: 24px;
background: rgba(255, 255, 255, 0.96);
box-shadow: var(--editor-shadow);
}
.policy-block-editor .preview-mode-toggle {
display: inline-flex;
border: 1px solid var(--editor-border);
border-radius: 999px;
padding: 0.25rem;
background: var(--editor-bg);
gap: 0.25rem;
}
.policy-block-editor .segment-button {
border: 0;
background: transparent;
color: var(--editor-muted);
padding: 0.55rem 0.95rem;
border-radius: 999px;
font-weight: 600;
}
.policy-block-editor .segment-button.is-active {
background: #0f172a;
color: #fff;
}
.policy-block-editor .editor-panel,
.policy-block-editor .preview-panel {
padding: 1.25rem;
position: sticky;
top: 92px;
}
.policy-block-editor .editor-panel {
min-height: calc(100vh - 210px);
}
.policy-block-editor .preview-panel {
min-height: calc(100vh - 210px);
}
.policy-block-editor .panel-toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
margin-bottom: 1rem;
}
.policy-block-editor .editor-toolbar-main,
.policy-block-editor .preview-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
}
.policy-block-editor .editor-toolbar-main {
flex: 1 1 360px;
}
.policy-block-editor .editor-toolbar-secondary {
display: none;
}
.policy-block-editor .preview-toolbar-actions {
justify-content: flex-end;
}
.policy-block-editor .editor-toolbar-title {
min-width: 220px;
margin-right: 0.35rem;
}
.policy-block-editor .editor-scroll {
max-height: calc(100vh - 340px);
overflow: auto;
padding-right: 0.35rem;
}
.policy-block-editor .validation-banner {
display: none;
gap: 0.75rem;
align-items: flex-start;
padding: 0.9rem 1rem;
border-radius: 18px;
border: 1px solid rgba(217, 119, 6, 0.25);
background: rgba(251, 191, 36, 0.12);
color: #92400e;
margin-bottom: 1rem;
}
.policy-block-editor .validation-banner.is-visible {
display: flex;
}
.policy-block-editor .block-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.policy-block-editor .block-card {
position: relative;
border: 1px solid var(--editor-border);
border-radius: 22px;
background: var(--editor-surface);
overflow: hidden;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.policy-block-editor .block-card:hover,
.policy-block-editor .block-card.is-selected {
border-color: rgba(184, 183, 106, 0.62);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
}
.policy-block-editor .block-card.is-invalid {
border-color: rgba(220, 38, 38, 0.55);
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.08);
}
.policy-block-editor .block-card.is-collapsed .block-body {
display: none;
}
.policy-block-editor .block-header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
padding: 0.9rem 1rem;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.95), rgba(255, 255, 255, 0.96));
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.policy-block-editor .block-title {
display: flex;
gap: 0.75rem;
align-items: center;
min-width: 0;
}
.policy-block-editor .block-index {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: rgba(184, 183, 106, 0.12);
color: #0f172a;
font-weight: 700;
}
.policy-block-editor .block-actions {
display: flex;
align-items: center;
gap: 0.45rem;
opacity: 0.18;
transition: opacity 0.16s ease;
}
.policy-block-editor .block-card:hover .block-actions,
.policy-block-editor .block-card.is-selected .block-actions {
opacity: 1;
}
.policy-block-editor .icon-button {
width: 34px;
height: 34px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 12px;
background: #fff;
color: #334155;
display: inline-flex;
align-items: center;
justify-content: center;
}
.policy-block-editor .icon-button:hover {
border-color: rgba(184, 183, 106, 0.65);
color: #0f172a;
}
.policy-block-editor .icon-button.is-danger:hover {
border-color: rgba(220, 38, 38, 0.55);
color: var(--editor-danger);
}
.policy-block-editor .drag-handle {
cursor: grab;
}
.policy-block-editor .drag-handle:active {
cursor: grabbing;
}
.policy-block-editor .block-body {
padding: 1rem;
}
.policy-block-editor .block-content {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 18px;
background: #fff;
min-height: 108px;
padding: 0.9rem 1rem;
outline: none;
}
.policy-block-editor .block-content:focus {
border-color: rgba(184, 183, 106, 0.65);
box-shadow: 0 0 0 3px rgba(184, 183, 106, 0.12);
}
.policy-block-editor .block-content[data-placeholder]:empty::before,
.policy-block-editor .list-item-content[data-placeholder]:empty::before {
content: attr(data-placeholder);
color: #94a3b8;
}
.policy-block-editor .block-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 0.9rem;
}
.policy-block-editor .list-items {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.policy-block-editor .list-item {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.75rem;
align-items: start;
padding: 0.75rem;
border-radius: 18px;
background: var(--editor-bg);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.policy-block-editor .list-item-content {
min-height: 70px;
padding: 0.65rem 0.8rem;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: #fff;
outline: none;
}
.policy-block-editor .preview-frame {
border-radius: 28px;
background: linear-gradient(180deg, #f8fafc, #ffffff);
border: 1px solid rgba(148, 163, 184, 0.16);
padding: 1rem;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
}
.policy-block-editor .preview-device {
margin: 0 auto;
min-width: 0;
}
.policy-block-editor .preview-device[data-mode="desktop"] {
width: 1120px;
}
.policy-block-editor .preview-device[data-mode="tablet"] {
width: 820px;
}
.policy-block-editor .preview-device[data-mode="mobile"] {
width: 390px;
}
.policy-block-editor .preview-canvas {
background: #fff;
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.14);
padding: 2rem;
min-height: 480px;
}
.policy-block-editor .preview-device[data-mode="desktop"] .preview-canvas {
min-height: 720px;
padding: 2.5rem;
}
.policy-block-editor .preview-device[data-mode="desktop"] #previewContent {
max-width: none;
}
.policy-block-editor .preview-block {
padding: 0.65rem 0;
border-radius: 14px;
scroll-margin-top: 140px;
}
.policy-block-editor .preview-block.is-highlighted {
background: rgba(184, 183, 106, 0.12);
outline: 1px solid rgba(184, 183, 106, 0.35);
}
.policy-block-editor .preview-block.is-targeted {
background: rgba(37, 99, 235, 0.1);
outline: 1px solid rgba(37, 99, 235, 0.45);
box-shadow: 0 0 0 6px rgba(37, 99, 235, 0.08);
animation: preview-target-pulse 0.9s ease;
}
@keyframes preview-target-pulse {
0% {
transform: scale(0.985);
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.18);
}
55% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba(37, 99, 235, 0.08);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 6px rgba(37, 99, 235, 0.08);
}
}
.policy-block-editor .callout-block {
border-radius: 22px;
padding: 1.1rem 1.2rem;
border: 1px solid rgba(148, 163, 184, 0.16);
background: #f8fafc;
}
.policy-block-editor .callout-block[data-tone="warning"] {
background: rgba(251, 191, 36, 0.1);
border-color: rgba(217, 119, 6, 0.28);
}
.policy-block-editor .callout-block[data-tone="success"] {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(21, 128, 61, 0.22);
}
#floatingToolbar,
#linkPopover,
#slashMenu {
position: fixed;
z-index: 1045;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.96);
color: #fff;
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.24);
padding: 0.55rem;
display: none;
}
#floatingToolbar.is-visible,
#linkPopover.is-visible,
#slashMenu.is-visible {
display: block;
}
#floatingToolbar .floating-toolbar-group {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
#floatingToolbar button,
#slashMenu button {
border: 0;
border-radius: 12px;
padding: 0.5rem 0.65rem;
background: transparent;
color: #fff;
}
#floatingToolbar button:hover,
#slashMenu button:hover {
background: rgba(255, 255, 255, 0.12);
}
#slashMenu .slash-menu-list {
display: flex;
flex-direction: column;
min-width: 210px;
gap: 0.25rem;
}
.policy-block-editor .editor-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1035;
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 1.25rem;
background: rgba(255, 255, 255, 0.96);
border-top: 1px solid rgba(148, 163, 184, 0.14);
box-shadow: 0 -18px 36px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(10px);
}
.policy-block-editor .editor-footer .btn {
min-width: 136px;
}
.policy-block-editor .drop-indicator {
height: 4px;
border-radius: 999px;
background: linear-gradient(90deg, #0f172a, rgba(184, 183, 106, 0.92));
margin: -0.35rem 0 0.65rem;
display: none;
}
.policy-block-editor .drop-indicator.is-visible {
display: block;
}
.policy-block-editor .sortable-ghost {
opacity: 0.35;
}
.policy-block-editor .sortable-chosen {
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.16);
}
@media (max-width: 1200px) {
.policy-block-editor .editor-shell {
grid-template-columns: 1fr;
}
.policy-block-editor .editor-panel,
.policy-block-editor .preview-panel {
position: static;
min-height: auto;
}
.policy-block-editor .editor-scroll {
max-height: none;
}
}
@media (max-width: 768px) {
.policy-block-editor .block-meta-grid {
grid-template-columns: 1fr;
}
.policy-block-editor .editor-toolbar-secondary,
.policy-block-editor .preview-toolbar-actions {
justify-content: flex-start;
}
.policy-block-editor .editor-footer {
flex-direction: column;
}
.policy-block-editor .editor-footer .btn,
.policy-block-editor .editor-footer a {
width: 100%;
}
}
</style>
<div
class="container policy-block-editor"
id="policyBlockEditor"
data-policy-id="<%= editorConfig.policyId %>"
data-route-base="<%= editorConfig.routeBase %>"
>
<form action="<%= editorConfig.routeBase %>/update" method="POST" id="policyBlockEditorForm" novalidate>
<input type="hidden" name="pageJson" id="pageJson" />
<input type="hidden" name="intent" id="editorIntent" value="save" />
<div class="editor-shell">
<section class="editor-panel">
<div class="panel-toolbar">
<div class="editor-toolbar-main">
<div class="editor-toolbar-title">
<p class="text-uppercase text-muted small fw-semibold mb-1">Modern CMS Editor</p>
<h1 class="h4 mb-0" style="color: var(--primary-dark)"><%= data.policy.title %></h1>
</div>
<select class="form-select" id="blockTypeSelect" style="min-width: 180px">
<option value="paragraph">Paragraph</option>
<option value="heading">Heading</option>
<option value="list">List</option>
<option value="quote">Quote</option>
<option value="callout">Callout</option>
<option value="divider">Divider</option>
</select>
<button type="button" class="btn btn-primary" id="addBlockButton">
<i class="fas fa-plus me-2"></i>Add block
</button>
</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 class="validation-banner" id="validationBanner">
<i class="fas fa-triangle-exclamation mt-1"></i>
<div>
<div class="fw-semibold">Validation needed before saving</div>
<div id="validationSummary" class="small"></div>
</div>
</div>
</form>
<div class="visual-editor">
<div class="editor-scroll">
<div class="drop-indicator" id="dropIndicator"></div>
<div class="block-list" id="blockList"></div>
</div>
</div>
</section>
<aside class="preview-panel">
<div class="panel-toolbar">
<div>
<h2 class="h6 mb-1">Realtime Preview</h2>
<p class="text-muted small mb-0">Semantic rendering of the current content.</p>
</div>
<div class="preview-toolbar-actions">
<div class="preview-mode-toggle" data-role="preview-mode-toggle">
<button type="button" class="segment-button is-active" data-preview-mode="desktop">Desktop</button>
<button type="button" class="segment-button" data-preview-mode="tablet">Tablet</button>
<button type="button" class="segment-button" data-preview-mode="mobile">Mobile</button>
</div>
</div>
</div>
<div class="preview-frame">
<div class="preview-device" id="previewDevice" data-mode="desktop">
<div class="preview-canvas">
<div class="mb-4">
<div class="text-uppercase small fw-semibold text-muted mb-2"><%= data.policy.navLabel %></div>
<h2 class="h3 mb-2"><%= data.policy.title %></h2>
<% if (data.policy.effectiveDate) { %>
<p class="text-muted mb-2"><%= data.policy.effectiveDate %></p>
<% } %>
<% if (data.policy.intro) { %>
<p class="text-muted mb-0"><%= data.policy.intro %></p>
<% } %>
</div>
<div id="previewContent"></div>
</div>
</div>
</div>
</aside>
</div>
<div class="editor-footer">
<div class="d-flex gap-2 flex-wrap">
<a href="/admin/policies?tab=policies" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Back
</a>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-outline-primary" data-intent="save-back">
<i class="fas fa-arrow-turn-left me-2"></i>Save &amp; Back
</button>
<button type="submit" class="btn btn-primary" data-intent="save">
<i class="fas fa-save me-2"></i>Save Content
</button>
</div>
</div>
</form>
</div>
<div class="floating-toolbar" id="floatingToolbar">
<div class="floating-toolbar-group">
<button type="button" data-command="bold"><i class="fas fa-bold"></i></button>
<button type="button" data-command="italic"><i class="fas fa-italic"></i></button>
<button type="button" data-command="underline"><i class="fas fa-underline"></i></button>
<button type="button" data-command="strikeThrough"><i class="fas fa-strikethrough"></i></button>
<button type="button" data-command="toggleCode"><i class="fas fa-code"></i></button>
<button type="button" data-command="toggleHighlight"><i class="fas fa-highlighter"></i></button>
<button type="button" data-command="subscript"><i class="fas fa-subscript"></i></button>
<button type="button" data-command="superscript"><i class="fas fa-superscript"></i></button>
<input type="color" title="Text color" data-command="foreColor" value="#0f172a" />
<input type="color" title="Background color" data-command="hiliteColor" value="#fef08a" />
<button type="button" data-command="openLink"><i class="fas fa-link"></i></button>
</div>
</div>
<script>
window.pageEditorConfig = <%- JSON.stringify(editorConfig) %>;
window.pageEditorData = <%- JSON.stringify(data) %>;
window.pageEditorBackendUrl = <%- JSON.stringify(backendUrl) %>;
</script>
<%- include("partials/sections-editor-script") %>
<div class="link-popover" id="linkPopover">
<div class="mb-2 fw-semibold">Edit link</div>
<div class="mb-2">
<label class="form-label text-white-50 small">URL</label>
<input type="text" class="form-control" id="linkUrlInput" placeholder="https://example.com" />
</div>
<div class="mb-3">
<label class="form-label text-white-50 small">Internal page</label>
<select class="form-select" id="linkPolicySelect">
<option value="">None</option>
<% (editorConfig.policyOptions || []).forEach((option) => { %>
<option value="<%= option.value %>"><%= option.label %></option>
<% }) %>
</select>
</div>
<div class="d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-outline-light btn-sm" id="removeLinkButton">Remove</button>
<button type="button" class="btn btn-primary btn-sm" id="saveLinkButton">Apply</button>
</div>
</div>
<div class="slash-menu" id="slashMenu">
<div class="slash-menu-list">
<button type="button" data-slash-type="heading">Heading</button>
<button type="button" data-slash-type="list">List</button>
<button type="button" data-slash-type="divider">Divider</button>
<button type="button" data-slash-type="quote">Quote</button>
<button type="button" data-slash-type="link">Link</button>
<button type="button" disabled>Image (Coming soon)</button>
</div>
</div>
<script type="application/json" id="policyBlockEditorDataPayload"><%- JSON.stringify(data) %></script>
<script type="application/json" id="policyBlockEditorConfigData"><%- JSON.stringify(editorConfig) %></script>
<script src="/js/policies-block-editor.js"></script>