From cdaddddfeb1f500ad6735d02b35bbf2f625f2b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Th=C3=A0nh=20=C4=90=E1=BA=A1t?= <84076965+tongthanhdat009@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:14:54 +0700 Subject: [PATCH] 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 --- controllers/policiesController.js | 62 +- data/policies.json | 353 ++++-- public/js/policies-block-editor.js | 1092 +++++++++++++++++ utils/contentEditors/policiesConfig.js | 114 +- utils/policiesBlockContent.js | 404 ++++++ views/admin/policies/index.ejs | 8 +- .../admin/policies/partials/editor-script.ejs | 17 +- .../partials/sections-editor-script.ejs | 947 -------------- .../admin/policies/partials/sections-tab.ejs | 13 - views/admin/policies/sections.ejs | 693 ++++++++++- 10 files changed, 2438 insertions(+), 1265 deletions(-) create mode 100644 public/js/policies-block-editor.js create mode 100644 utils/policiesBlockContent.js delete mode 100644 views/admin/policies/partials/sections-editor-script.ejs delete mode 100644 views/admin/policies/partials/sections-tab.ejs diff --git a/controllers/policiesController.js b/controllers/policiesController.js index 2428991..9a00ee4 100644 --- a/controllers/policiesController.js +++ b/controllers/policiesController.js @@ -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); diff --git a/data/policies.json b/data/policies.json index 2ff9360..1b71b0f 100644 --- a/data/policies.json +++ b/data/policies.json @@ -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": "1. Information We Collect QA 20260422 041603" + }, + { + "id": "1-information-we-collect-qa-20260422-041603-intro", + "type": "paragraph", + "html": "

We collect information directly from students and applicants. Validation marker QA 20260422 041603.

" + }, + { + "id": "block-1776821411652-j34gz", + "type": "quote", + "html": "

Quote QA 20260422 0127

", + "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": "

Personal identification information such as name, address, email address, phone number, date of birth, and government-issued ID numbers where required.

" + }, + { + "id": "financial-information-such-as-payment-details-financial-aid-applications-and-billing-history", + "html": "

Financial information such as payment details, financial aid applications, and billing history.

" + }, + { + "id": "audit-ready-retention-notice-qa-20260422-041603", + "html": "

Audit-ready retention notice QA 20260422 041603

" + } + ] + }, + { + "id": "3-data-sharing-and-third-parties", + "type": "heading", + "level": 2, + "html": "3. Data Sharing and Third Parties" + }, + { + "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": "

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.

" + }, + { + "id": "if-you-have-questions-about-this-policy-please-contact-our-data-protection-officer-at-privacy-lams-edu", + "type": "paragraph", + "html": "

If you have questions about this policy, please contact our Data Protection Officer at privacy@LAMS.edu.

" + }, + { + "id": "for-policy-navigation-see-terms-of-use-qa-20260422-041603", + "type": "paragraph", + "html": "

For policy navigation, see Terms of Use QA 20260422 041603.

" + }, + { + "id": "2-how-we-use-your-information", + "type": "heading", + "level": 2, + "html": "2. How We Use Your Information" + }, + { + "id": "2-how-we-use-your-information-intro", + "type": "paragraph", + "html": "

Your information is primarily used to provide educational services and manage your student journey. Specific uses include:

" + }, + { + "id": "2-how-we-use-your-information-2", + "type": "list", + "style": "unordered", + "items": [ + { + "id": "processing-admissions-applications-and-enrollment", + "html": "

Processing admissions applications and enrollment.

" + }, + { + "id": "delivering-course-materials-grades-and-academic-advising", + "html": "

Delivering course materials, grades, and academic advising.

" + }, + { + "id": "processing-tuition-payments-and-administering-financial-aid", + "html": "

Processing tuition payments and administering financial aid.

" + }, + { + "id": "communicating-important-university-updates-and-policy-changes", + "html": "

Communicating important university updates and policy changes.

" + } + ] + } + ] + } }, { "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": "1. Academic Integrity" + }, + { + "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": "

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.

" + }, + { + "id": "2-account-security", + "type": "heading", + "level": 2, + "html": "2. Account Security" + }, + { + "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": "

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.

" + }, + { + "id": "course-materials", + "type": "callout", + "tone": "info", + "title": "Course Materials", + "icon": "fa-book-open", + "html": "

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.

" + }, + { + "id": "subscription-terms", + "type": "callout", + "tone": "info", + "title": "Subscription Terms", + "icon": "fa-credit-card", + "html": "

Monthly subscriptions automatically renew unless canceled prior to the billing cycle. See the Financial Policies for refund criteria.

Financial Policies

" + } + ] + } }, { "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": "1. Our Accessibility Commitments" + }, + { + "id": "1-our-accessibility-commitments-2", + "type": "list", + "style": "unordered", + "items": [ + { + "id": "we-design-learning-materials-and-digital-services-with-accessibility-in-mind", + "html": "

We design learning materials and digital services with accessibility in mind.

" + }, + { + "id": "we-review-core-student-journeys-for-keyboard-access-screen-reader-support-and-readable-contrast", + "html": "

We review core student journeys for keyboard access, screen reader support, and readable contrast.

" + }, + { + "id": "we-provide-reasonable-accommodations-through-our-student-support-and-advising-teams", + "html": "

We provide reasonable accommodations through our student support and advising teams.

" + } + ] + }, + { + "id": "2-requesting-support", + "type": "heading", + "level": 2, + "html": "2. Requesting Support" + }, + { + "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": "

If you encounter an accessibility barrier, contact our support team so we can review the issue and provide an appropriate path forward.

" + } + ] + } }, { "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": "1. Cookie Categories" + }, + { + "id": "1-cookie-categories-2", + "type": "list", + "style": "unordered", + "items": [ + { + "id": "essential-cookies-keep-core-services-such-as-authentication-and-security-running", + "html": "

Essential cookies keep core services such as authentication and security running.

" + }, + { + "id": "analytics-cookies-help-us-understand-aggregate-site-usage-and-improve-content", + "html": "

Analytics cookies help us understand aggregate site usage and improve content.

" + }, + { + "id": "preference-cookies-remember-non-sensitive-choices-such-as-language-and-display-settings", + "html": "

Preference cookies remember non-sensitive choices such as language and display settings.

" + } + ] + }, + { + "id": "2-managing-preferences", + "type": "heading", + "level": 2, + "html": "2. Managing Preferences" + }, + { + "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": "

You can manage cookies through your browser settings. Some essential cookies cannot be disabled because they are required for secure access to student services.

" + } + ] + } }, { "navLabel": "hehehe", @@ -180,7 +265,9 @@ "effectiveDate": "hehehe", "intro": "hehehe", "id": "hehehe", - "sections": [] + "content": { + "blocks": [] + } } ] } \ No newline at end of file diff --git a/public/js/policies-block-editor.js b/public/js/policies-block-editor.js new file mode 100644 index 0000000..0150cf6 --- /dev/null +++ b/public/js/policies-block-editor.js @@ -0,0 +1,1092 @@ +(function () { + const root = document.getElementById("policyBlockEditor"); + const form = document.getElementById("policyBlockEditorForm"); + const pageJsonInput = document.getElementById("pageJson"); + const intentInput = document.getElementById("editorIntent"); + const blockList = document.getElementById("blockList"); + const previewContent = document.getElementById("previewContent"); + const blockTypeSelect = document.getElementById("blockTypeSelect"); + const addBlockButton = document.getElementById("addBlockButton"); + const validationBanner = document.getElementById("validationBanner"); + const validationSummary = document.getElementById("validationSummary"); + const previewDevice = document.getElementById("previewDevice"); + const dropIndicator = document.getElementById("dropIndicator"); + const floatingToolbar = document.getElementById("floatingToolbar"); + const linkPopover = document.getElementById("linkPopover"); + const slashMenu = document.getElementById("slashMenu"); + const linkUrlInput = document.getElementById("linkUrlInput"); + const linkPolicySelect = document.getElementById("linkPolicySelect"); + const saveLinkButton = document.getElementById("saveLinkButton"); + const removeLinkButton = document.getElementById("removeLinkButton"); + const data = readJsonScript("policyBlockEditorDataPayload"); + const config = readJsonScript("policyBlockEditorConfigData"); + + if (!root || !form || !pageJsonInput || !blockList || !previewContent || !data || !config) { + return; + } + + const defaultContent = { + content: { blocks: [] }, + }; + + const state = JSON.parse(JSON.stringify(data.content ? data : defaultContent)); + state.policy = data.policy; + state.content = state.content || defaultContent.content; + + const ui = { + previewMode: "desktop", + selectedBlockId: null, + targetedPreviewBlockId: null, + hoveredPreviewBlockId: null, + pendingLinkRange: null, + slashContext: null, + toolbarTarget: null, + sortable: null, + previewTargetTimer: null, + }; + + const stylePalette = [ + { value: "info", label: "Info" }, + { value: "warning", label: "Warning" }, + { value: "success", label: "Success" }, + ]; + + bindStaticEvents(); + render(); + window.policyBlockEditorDebug = { + getState: function () { + return JSON.parse(JSON.stringify(state)); + }, + moveBlockById: function (language, blockId, newIndex) { + const blocks = getBlocks(); + const oldIndex = blocks.findIndex((block) => block.id === blockId); + if (oldIndex === -1) { + return false; + } + moveBlock(oldIndex, newIndex); + return true; + }, + }; + + function bindStaticEvents() { + root.querySelectorAll("[data-preview-mode]").forEach((button) => { + button.addEventListener("click", function () { + ui.previewMode = this.dataset.previewMode; + updatePreviewModeButtons(); + }); + }); + + addBlockButton.addEventListener("click", function () { + insertBlock(blockTypeSelect.value || "paragraph"); + }); + + form.querySelectorAll("[data-intent]").forEach((button) => { + button.addEventListener("click", function () { + intentInput.value = this.dataset.intent || "save"; + }); + }); + + form.addEventListener("submit", function (event) { + const validation = validateState(); + if (validation.errors.length > 0) { + event.preventDefault(); + renderValidation(validation); + showToast("Validation", "Fix the highlighted content before saving.", "danger"); + return; + } + + pageJsonInput.value = JSON.stringify({ + content: state.content, + }); + }); + + document.addEventListener("selectionchange", handleSelectionChange); + document.addEventListener("click", handleGlobalClick); + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + hideFloatingToolbar(); + hideLinkPopover(); + hideSlashMenu(); + } + }); + + floatingToolbar.addEventListener("mousedown", function (event) { + event.preventDefault(); + }); + + floatingToolbar.addEventListener("click", function (event) { + const target = event.target.closest("[data-command]"); + if (!target) { + return; + } + + const command = target.dataset.command; + if (command === "openLink") { + openLinkPopover(); + return; + } + + if (target.matches('input[type="color"]')) { + return; + } + + applyCommand(command); + }); + + floatingToolbar.querySelectorAll('input[type="color"]').forEach((input) => { + input.addEventListener("input", function () { + applyCommand(this.dataset.command, this.value); + }); + }); + + saveLinkButton.addEventListener("click", applyLinkFromPopover); + removeLinkButton.addEventListener("click", removeCurrentLink); + + slashMenu.querySelectorAll("[data-slash-type]").forEach((button) => { + button.addEventListener("click", function () { + insertSlashCommand(this.dataset.slashType); + }); + }); + } + + function getBlocks() { + return state.content.blocks; + } + + function setBlockSelection(blockId) { + ui.selectedBlockId = blockId; + renderBlockSelection(); + renderPreviewSelection(); + } + + function render() { + hideFloatingToolbar(); + hideLinkPopover(); + hideSlashMenu(); + updatePreviewModeButtons(); + renderBlocks(); + renderPreview(); + renderValidation(validateState()); + } + + function updatePreviewModeButtons() { + root.querySelectorAll("[data-preview-mode]").forEach((button) => { + button.classList.toggle("is-active", button.dataset.previewMode === ui.previewMode); + }); + + previewDevice.dataset.mode = ui.previewMode; + } + + function createBlock(type) { + const id = `block-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + if (type === "heading") { + return { + id, + type, + level: 2, + html: "", + }; + } + + if (type === "list") { + return { + id, + type, + style: "unordered", + items: [ + { id: `${id}-item-1`, html: "" }, + { id: `${id}-item-2`, html: "" }, + ], + }; + } + + if (type === "quote") { + return { + id, + type, + html: "", + caption: "", + }; + } + + if (type === "divider") { + return { id, type }; + } + + if (type === "callout") { + return { + id, + type, + tone: "info", + title: "", + icon: "fa-circle-info", + html: "", + }; + } + + return { + id, + type: "paragraph", + html: "", + }; + } + + function insertBlock(type, index) { + const blocks = getBlocks(); + const nextBlock = createBlock(type); + const insertIndex = typeof index === "number" ? index : blocks.length; + blocks.splice(insertIndex, 0, nextBlock); + setBlockSelection(nextBlock.id); + render(); + } + + function duplicateBlock(blockId) { + const blocks = getBlocks(); + const index = blocks.findIndex((block) => block.id === blockId); + if (index === -1) { + return; + } + + const source = JSON.parse(JSON.stringify(blocks[index])); + source.id = `block-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + if (source.items) { + source.items = source.items.map((item, itemIndex) => ({ + ...item, + id: `${source.id}-item-${itemIndex + 1}`, + })); + } + + blocks.splice(index + 1, 0, source); + setBlockSelection(source.id); + render(); + } + + function deleteBlock(blockId) { + const blocks = getBlocks(); + const index = blocks.findIndex((block) => block.id === blockId); + if (index === -1) { + return; + } + + blocks.splice(index, 1); + if (ui.selectedBlockId === blockId) { + ui.selectedBlockId = null; + } + render(); + } + + function moveBlock(oldIndex, newIndex) { + const blocks = getBlocks(); + if ( + oldIndex === newIndex || + oldIndex < 0 || + newIndex < 0 || + oldIndex >= blocks.length || + newIndex >= blocks.length + ) { + return; + } + + const [moved] = blocks.splice(oldIndex, 1); + blocks.splice(newIndex, 0, moved); + render(); + } + + function renderBlocks() { + const blocks = getBlocks(); + blockList.innerHTML = ""; + + if (blocks.length === 0) { + const empty = document.createElement("div"); + empty.className = "text-muted border rounded-4 p-4 text-center"; + empty.innerHTML = 'No blocks yet. Add your first block to start editing.'; + blockList.appendChild(empty); + return; + } + + const validation = validateState(); + + blocks.forEach((block, index) => { + const card = document.createElement("article"); + card.className = "block-card"; + card.dataset.blockId = block.id; + card.classList.toggle( + "is-invalid", + validation.blockIssues[block.id]?.length > 0, + ); + card.classList.toggle("is-selected", ui.selectedBlockId === block.id); + + card.innerHTML = ` +
+
+
${index + 1}
+
+
${block.type}
+
${describeBlock(block)}
+
+
+
+ + + + + +
+
+
+ `; + + const body = card.querySelector(".block-body"); + renderBlockBody(block, body, index); + + card.addEventListener("click", function () { + setBlockSelection(block.id); + }); + + card.querySelector('[data-action="duplicate"]').addEventListener("click", function (event) { + event.stopPropagation(); + duplicateBlock(block.id); + }); + + card.querySelector('[data-action="target-preview"]').addEventListener("click", function (event) { + event.stopPropagation(); + focusPreviewBlock(block.id); + }); + + card.querySelector('[data-action="delete"]').addEventListener("click", function (event) { + event.stopPropagation(); + deleteBlock(block.id); + }); + + card.querySelector('[data-action="collapse"]').addEventListener("click", function (event) { + event.stopPropagation(); + card.classList.toggle("is-collapsed"); + }); + + blockList.appendChild(card); + }); + + initializeSortable(); + renderBlockSelection(); + } + + function initializeSortable() { + if (ui.sortable) { + ui.sortable.destroy(); + ui.sortable = null; + } + + if (!window.Sortable || blockList.children.length < 2) { + return; + } + + ui.sortable = window.Sortable.create(blockList, { + animation: 180, + handle: ".drag-handle", + ghostClass: "sortable-ghost", + chosenClass: "sortable-chosen", + onStart: function () { + dropIndicator.classList.remove("is-visible"); + }, + onEnd: function (event) { + if ( + typeof event.oldIndex !== "number" || + typeof event.newIndex !== "number" || + event.oldIndex === event.newIndex + ) { + return; + } + + moveBlock(event.oldIndex, event.newIndex); + }, + }); + } + + function renderBlockBody(block, body, index) { + if (block.type === "heading") { + body.innerHTML = ` +
+
+ + +
+
+ `; + const editor = createEditable(block.html, "Heading text"); + editor.dataset.blockId = block.id; + editor.dataset.field = "html"; + body.appendChild(editor); + bindEditable(editor, function (value) { + block.html = value; + }); + body.querySelector('[data-field="level"]').addEventListener("change", function () { + block.level = Number(this.value); + render(); + }); + return; + } + + if (block.type === "paragraph") { + const editor = createEditable(block.html, "Write a paragraph. Type / for commands."); + editor.dataset.blockId = block.id; + editor.dataset.field = "html"; + body.appendChild(editor); + bindEditable(editor, function (value) { + block.html = value; + }, { slashEnabled: true, blockIndex: index }); + return; + } + + if (block.type === "quote") { + body.innerHTML = ` +
+
+ + +
+
+ `; + const editor = createEditable(block.html, "Quote content"); + editor.dataset.blockId = block.id; + editor.dataset.field = "html"; + body.appendChild(editor); + bindEditable(editor, function (value) { + block.html = value; + }); + body.querySelector('[data-field="caption"]').addEventListener("input", function () { + block.caption = this.value; + renderPreview(); + renderValidation(validateState()); + }); + return; + } + + if (block.type === "divider") { + body.innerHTML = ` +
+ Divider blocks render as a semantic separator in the preview. They are useful between long content groups. +
+ `; + return; + } + + if (block.type === "callout") { + body.innerHTML = ` +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + const editor = createEditable(block.html, "Callout body"); + editor.dataset.blockId = block.id; + editor.dataset.field = "html"; + body.appendChild(editor); + bindEditable(editor, function (value) { + block.html = value; + }); + + body.querySelectorAll("[data-field]").forEach((input) => { + input.addEventListener("input", function () { + block[this.dataset.field] = this.value; + renderPreview(); + renderValidation(validateState()); + }); + }); + return; + } + + body.innerHTML = ` +
+
+ + +
+
+
+ + `; + + body.querySelector('[data-field="style"]').addEventListener("change", function () { + block.style = this.value; + renderPreview(); + renderValidation(validateState()); + }); + + const listItems = body.querySelector(".list-items"); + (block.items || []).forEach((item, itemIndex) => { + const row = document.createElement("div"); + row.className = "list-item"; + row.innerHTML = ` +
${itemIndex + 1}
+
+ + `; + const content = row.querySelector(".list-item-content"); + content.innerHTML = item.html || ""; + content.dataset.blockId = block.id; + content.dataset.itemId = item.id; + bindEditable(content, function (value) { + item.html = value; + }, { slashEnabled: true, blockIndex: index }); + row.querySelector("button").addEventListener("click", function () { + block.items.splice(itemIndex, 1); + render(); + }); + listItems.appendChild(row); + }); + + body.querySelector('[data-action="add-list-item"]').addEventListener("click", function () { + block.items.push({ + id: `${block.id}-item-${Date.now()}`, + html: "", + }); + render(); + }); + } + + function createEditable(html, placeholder) { + const editor = document.createElement("div"); + editor.className = "block-content"; + editor.contentEditable = "true"; + editor.spellcheck = true; + editor.dataset.placeholder = placeholder || ""; + editor.innerHTML = html || ""; + return editor; + } + + function bindEditable(element, onChange, options) { + element.addEventListener("input", function () { + normalizeEditorHtml(element); + onChange(element.innerHTML); + renderPreview(); + renderValidation(validateState()); + }); + + element.addEventListener("focus", function () { + ui.toolbarTarget = element; + setBlockSelection(element.dataset.blockId); + }); + + element.addEventListener("keydown", function (event) { + if (event.key === "Tab" && element.closest(".list-item-content")) { + event.preventDefault(); + document.execCommand("insertHTML", false, "    "); + return; + } + + if (options?.slashEnabled && event.key === "/") { + ui.slashContext = { + blockIndex: options.blockIndex, + blockId: element.dataset.blockId, + target: element, + }; + const range = window.getSelection()?.getRangeAt(0); + if (range) { + window.setTimeout(function () { + showSlashMenu(range); + }, 0); + } + } + }); + } + + function normalizeEditorHtml(element) { + if (!element.innerHTML.trim()) { + element.innerHTML = ""; + } + } + + function handleSelectionChange() { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + hideFloatingToolbar(); + return; + } + + const anchorNode = selection.anchorNode?.parentElement; + const editable = anchorNode?.closest(".block-content, .list-item-content"); + if (!editable) { + hideFloatingToolbar(); + return; + } + + ui.toolbarTarget = editable; + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + if (!rect.width && !rect.height) { + hideFloatingToolbar(); + return; + } + + floatingToolbar.style.top = `${Math.max(rect.top - 56, 12)}px`; + floatingToolbar.style.left = `${Math.max(rect.left + rect.width / 2 - 160, 12)}px`; + floatingToolbar.classList.add("is-visible"); + } + + function handleGlobalClick(event) { + if (!floatingToolbar.contains(event.target) && !event.target.closest(".block-content, .list-item-content")) { + hideFloatingToolbar(); + } + + if (!linkPopover.contains(event.target) && !event.target.closest('[data-command="openLink"]')) { + hideLinkPopover(); + } + + if (!slashMenu.contains(event.target) && !event.target.closest(".block-content, .list-item-content")) { + hideSlashMenu(); + } + } + + function hideFloatingToolbar() { + floatingToolbar.classList.remove("is-visible"); + } + + function showSlashMenu(range) { + const rect = range.getBoundingClientRect(); + slashMenu.style.top = `${rect.bottom + 8}px`; + slashMenu.style.left = `${Math.max(rect.left, 12)}px`; + slashMenu.classList.add("is-visible"); + } + + function hideSlashMenu() { + slashMenu.classList.remove("is-visible"); + ui.slashContext = null; + } + + function insertSlashCommand(type) { + hideSlashMenu(); + + if (type === "link") { + openLinkPopover(); + return; + } + + if (!ui.slashContext) { + insertBlock(type); + return; + } + + const blocks = getBlocks(); + const index = blocks.findIndex((block) => block.id === ui.slashContext.blockId); + insertBlock(type, index + 1); + } + + function applyCommand(command, value) { + if (!ui.toolbarTarget) { + return; + } + + ui.toolbarTarget.focus(); + + if (command === "toggleCode") { + document.execCommand("insertHTML", false, `${getSelectedText()}`); + } else if (command === "toggleHighlight") { + document.execCommand("hiliteColor", false, "#fef08a"); + } else { + document.execCommand(command, false, value); + } + + ui.toolbarTarget.dispatchEvent(new Event("input", { bubbles: true })); + } + + function getSelectedText() { + return window.getSelection ? window.getSelection().toString() : ""; + } + + function openLinkPopover() { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + ui.pendingLinkRange = selection.getRangeAt(0).cloneRange(); + const rect = ui.pendingLinkRange.getBoundingClientRect(); + linkPopover.style.top = `${rect.bottom + 12}px`; + linkPopover.style.left = `${Math.max(rect.left - 60, 12)}px`; + linkPopover.classList.add("is-visible"); + + const anchor = selection.anchorNode?.parentElement?.closest("a"); + linkUrlInput.value = anchor?.getAttribute("href") || ""; + linkPolicySelect.value = anchor?.getAttribute("data-policy-id") || ""; + } + + function hideLinkPopover() { + linkPopover.classList.remove("is-visible"); + } + + function applyLinkFromPopover() { + if (!ui.pendingLinkRange) { + return; + } + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(ui.pendingLinkRange); + + const internalPolicyId = linkPolicySelect.value; + const href = linkUrlInput.value.trim(); + const selectedText = getSelectedText() || href || internalPolicyId || "link"; + + if (internalPolicyId) { + document.execCommand( + "insertHTML", + false, + `${selectedText}`, + ); + } else if (href) { + document.execCommand("createLink", false, href); + } + + if (ui.toolbarTarget) { + ui.toolbarTarget.dispatchEvent(new Event("input", { bubbles: true })); + } + + hideLinkPopover(); + } + + function removeCurrentLink() { + if (!ui.toolbarTarget) { + return; + } + + ui.toolbarTarget.focus(); + document.execCommand("unlink", false); + ui.toolbarTarget.dispatchEvent(new Event("input", { bubbles: true })); + hideLinkPopover(); + } + + function describeBlock(block) { + if (block.type === "heading") { + return `H${block.level || 2}`; + } + if (block.type === "list") { + return `${block.style === "ordered" ? "Numbered" : "Bullet"} list`; + } + if (block.type === "callout") { + return `${block.tone || "info"} callout`; + } + return "Rich text"; + } + + function validateState() { + const errors = []; + const warnings = []; + const blockIssues = {}; + const validPolicyIds = [state.policy.id].concat((config.policyOptions || []).map((item) => item.value)); + + let h1Count = 0; + getBlocks().forEach((block) => { + const issues = []; + if (block.type === "heading" && Number(block.level) === 1) { + h1Count += 1; + } + + if (block.type === "divider") { + blockIssues[block.id] = issues; + return; + } + + if (block.type === "list") { + if (!block.items?.length) { + issues.push("List needs at least one item."); + } + block.items?.forEach((item, index) => { + if (!stripHtml(item.html)) { + issues.push(`List item ${index + 1} is empty.`); + } + issues.push(...collectLinkIssues(item.html, validPolicyIds)); + }); + } else if (block.type === "callout") { + if (!stripHtml(block.html) && !stripHtml(block.title)) { + issues.push("Callout is empty."); + } + issues.push(...collectLinkIssues(block.html, validPolicyIds)); + } else if (!stripHtml(block.html)) { + issues.push("Block is empty."); + } else { + issues.push(...collectLinkIssues(block.html, validPolicyIds)); + } + + warnings.push(...issues); + blockIssues[block.id] = issues; + }); + + if (h1Count > 1) { + errors.push("Content has more than one H1."); + } + + if (getBlocks().length === 0) { + warnings.push("Content is empty."); + } + + return { + errors, + warnings, + blockIssues, + }; + } + + function collectLinkIssues(html, validPolicyIds) { + const issues = []; + const anchorRegex = /]*)>/gi; + let match = anchorRegex.exec(String(html || "")); + while (match) { + const attrs = {}; + String(match[1]).replace(/([a-zA-Z_:][a-zA-Z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)')/g, function (_, key, __, a, b) { + attrs[key] = a || b || ""; + return _; + }); + const href = String(attrs.href || "").trim(); + const policyId = String(attrs["data-policy-id"] || "").trim(); + const kind = String(attrs["data-link-kind"] || ""); + + if (kind === "internal") { + if (!policyId || !validPolicyIds.includes(policyId)) { + issues.push("Internal link target is invalid."); + } + } else if (!href || !/^(https?:\/\/|mailto:|tel:|\/|#)/i.test(href)) { + issues.push("Link URL is invalid."); + } + + match = anchorRegex.exec(String(html || "")); + } + + return issues; + } + + function renderValidation(validation) { + const hasMessages = validation.errors.length > 0 || validation.warnings.length > 0; + validationBanner.classList.toggle("is-visible", hasMessages); + validationSummary.innerHTML = hasMessages + ? validation.errors.concat(validation.warnings).slice(0, 8).map((item) => `
${escapeHtml(item)}
`).join("") + : ""; + + blockList.querySelectorAll(".block-card").forEach((card) => { + const issues = validation.blockIssues[card.dataset.blockId] || []; + card.classList.toggle("is-invalid", issues.length > 0); + }); + } + + function renderPreview() { + const blocks = getBlocks(); + previewContent.innerHTML = ""; + + if (blocks.length === 0) { + previewContent.innerHTML = '
No content yet.
'; + return; + } + + blocks.forEach((block) => { + const node = document.createElement("section"); + node.className = "preview-block"; + node.dataset.previewBlockId = block.id; + node.addEventListener("mouseenter", function () { + ui.hoveredPreviewBlockId = block.id; + renderBlockSelection(); + }); + node.addEventListener("mouseleave", function () { + ui.hoveredPreviewBlockId = null; + renderBlockSelection(); + }); + node.addEventListener("click", function () { + setBlockSelection(block.id); + document.querySelector(`[data-block-id="${block.id}"]`)?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }); + + if (block.type === "heading") { + const tagName = `h${block.level || 2}`; + const heading = document.createElement(tagName); + heading.className = block.level === 1 ? "display-6 fw-bold" : block.level === 2 ? "h3 fw-bold" : "h5 fw-semibold"; + heading.innerHTML = block.html || ""; + node.appendChild(heading); + } else if (block.type === "paragraph") { + const wrapper = document.createElement("div"); + wrapper.innerHTML = block.html || ""; + node.appendChild(wrapper); + } else if (block.type === "quote") { + const blockquote = document.createElement("blockquote"); + blockquote.className = "border-start border-4 ps-3 my-2"; + blockquote.innerHTML = block.html || ""; + node.appendChild(blockquote); + if (block.caption) { + const cite = document.createElement("div"); + cite.className = "small text-muted"; + cite.textContent = block.caption; + node.appendChild(cite); + } + } else if (block.type === "divider") { + const divider = document.createElement("hr"); + node.appendChild(divider); + } else if (block.type === "list") { + const list = document.createElement(block.style === "ordered" ? "ol" : "ul"); + list.className = "ps-4"; + (block.items || []).forEach((item) => { + const li = document.createElement("li"); + li.innerHTML = item.html || ""; + list.appendChild(li); + }); + node.appendChild(list); + } else if (block.type === "callout") { + const callout = document.createElement("div"); + callout.className = "callout-block"; + callout.dataset.tone = block.tone || "info"; + callout.innerHTML = ` + ${block.title ? `
${escapeHtml(block.title)}
` : ""} +
${block.html || ""}
+ `; + node.appendChild(callout); + } + + previewContent.appendChild(node); + }); + + renderPreviewSelection(); + bindPreviewInternalLinks(); + } + + function bindPreviewInternalLinks() { + previewContent.querySelectorAll("a[data-policy-id]").forEach((link) => { + link.addEventListener("click", function (event) { + event.preventDefault(); + showToast("Internal link", `Targets policy "${link.dataset.policyId}" on the public page.`, "info"); + }); + }); + } + + function renderBlockSelection() { + blockList.querySelectorAll(".block-card").forEach((card) => { + const isSelected = card.dataset.blockId === ui.selectedBlockId; + const isPreviewHovered = card.dataset.blockId === ui.hoveredPreviewBlockId; + card.classList.toggle("is-selected", isSelected || isPreviewHovered); + }); + } + + function renderPreviewSelection() { + previewContent.querySelectorAll(".preview-block").forEach((node) => { + const blockId = node.dataset.previewBlockId; + node.classList.toggle( + "is-highlighted", + blockId === ui.selectedBlockId || blockId === ui.hoveredPreviewBlockId, + ); + node.classList.toggle("is-targeted", blockId === ui.targetedPreviewBlockId); + }); + } + + function focusPreviewBlock(blockId) { + setBlockSelection(blockId); + const node = previewContent.querySelector(`[data-preview-block-id="${blockId}"]`); + if (!node) { + return; + } + + if (ui.previewTargetTimer) { + window.clearTimeout(ui.previewTargetTimer); + } + + ui.targetedPreviewBlockId = blockId; + renderPreviewSelection(); + node.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); + + ui.previewTargetTimer = window.setTimeout(function () { + ui.targetedPreviewBlockId = null; + renderPreviewSelection(); + ui.previewTargetTimer = null; + }, 1600); + } + + function escapeHtml(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function escapeAttribute(value) { + return escapeHtml(value).replace(/`/g, "`"); + } + + function stripHtml(value) { + return String(value || "") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/\s+/g, " ") + .trim(); + } + + function showToast(title, message, type) { + if (window.bootstrap && typeof bootstrap.Toast === "function") { + 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 = `
${escapeHtml(title)}: ${escapeHtml(message)}
`; + container.appendChild(toast); + new bootstrap.Toast(toast, { autohide: true, delay: 2500 }).show(); + toast.addEventListener("hidden.bs.toast", function () { + toast.remove(); + }); + return; + } + window.alert(`${title}: ${message}`); + } + + 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 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; + } + } +})(); diff --git a/utils/contentEditors/policiesConfig.js b/utils/contentEditors/policiesConfig.js index af9fa64..8b5a6d2 100644 --- a/utils/contentEditors/policiesConfig.js +++ b/utils/contentEditors/policiesConfig.js @@ -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, }; } diff --git a/utils/policiesBlockContent.js b/utils/policiesBlockContent.js new file mode 100644 index 0000000..720ce91 --- /dev/null +++ b/utils/policiesBlockContent.js @@ -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(//gi, " ") + .replace(/<\/p>/gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function escapeHtml(value = "") { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function ensureParagraphHtml(value = "") { + const raw = String(value || "").trim(); + if (!raw) { + return ""; + } + + if (/<[a-z][\s\S]*>/i.test(raw)) { + return raw; + } + + return `

${escapeHtml(raw)}

`; +} + +function createInternalAnchor(policyId, label) { + return `${escapeHtml(label)}`; +} + +function createExternalAnchor(href, label) { + return `${escapeHtml(label)}`; +} + +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 `

${html}

`; +} + +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: `${escapeHtml(heading)}`, + }); + } + + 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 + ? `

${createInternalAnchor(card.link.tabId, card.link.label || "Open")}

` + : card.link?.href + ? `

${createExternalAnchor(card.link.href, card.link.label || card.link.href)}

` + : ""; + + 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 = /]*)>/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, +}; diff --git a/views/admin/policies/index.ejs b/views/admin/policies/index.ejs index ac29ebc..baebcc2 100644 --- a/views/admin/policies/index.ejs +++ b/views/admin/policies/index.ejs @@ -52,11 +52,9 @@ - + + + <%- include("partials/editor-script") %> diff --git a/views/admin/policies/partials/editor-script.ejs b/views/admin/policies/partials/editor-script.ejs index ba83e46..c4c32b7 100644 --- a/views/admin/policies/partials/editor-script.ejs +++ b/views/admin/policies/partials/editor-script.ejs @@ -1,8 +1,8 @@ - diff --git a/views/admin/policies/partials/sections-tab.ejs b/views/admin/policies/partials/sections-tab.ejs deleted file mode 100644 index ce090e3..0000000 --- a/views/admin/policies/partials/sections-tab.ejs +++ /dev/null @@ -1,13 +0,0 @@ -
-
-
-
Sections
-
-
-
-
-
-
- - - diff --git a/views/admin/policies/sections.ejs b/views/admin/policies/sections.ejs index 61238b7..dcd72f6 100644 --- a/views/admin/policies/sections.ejs +++ b/views/admin/policies/sections.ejs @@ -1,60 +1,659 @@ -
-
-
-

<%= title %>

-

<%= subtitle %>

-
- -
+ + +
+
+ + + +
+
+
+
+
+

Modern CMS Editor

+

<%= data.policy.title %>

+
+ +
-
- - +
+ +
+
Validation needed before saving
+
+
- + +
+
+
+
+
+
+
+ +
+ + + +
+ +
+
+ + + + + + + + + + +
- -<%- include("partials/sections-editor-script") %> - - + +
+
+ + + + + + +
+
+ + +