(function () { const config = window.pageEditorConfig; const initialData = window.pageEditorData; const backendUrl = (window.pageEditorBackendUrl || "").replace(/\/$/, ""); const form = document.getElementById("pageContentForm"); const pageJsonInput = document.getElementById("pageJson"); const activeTabInput = document.getElementById("activeTabInput"); if (!config || !initialData || !form || !pageJsonInput || !activeTabInput) { return; } const state = JSON.parse(JSON.stringify(initialData)); const iconOptions = Array.from( new Set( (config.tabs || []) .flatMap((tab) => collectIcons(tab.schema)) .filter(Boolean), ), ); ensureIconDatalist(iconOptions); renderAllSections(); document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => { tabTrigger.addEventListener("shown.bs.tab", function () { const tabKey = this.dataset.tabKey; if (!tabKey) { return; } activeTabInput.value = tabKey; updateTabUrl(tabKey); }); }); form.addEventListener("submit", function () { pageJsonInput.value = JSON.stringify(state); }); form.addEventListener("reset", function () { window.setTimeout(function () { Object.keys(state).forEach((key) => delete state[key]); Object.assign(state, JSON.parse(JSON.stringify(initialData))); renderAllSections(); }, 0); }); function renderAllSections() { config.tabs.forEach((tab) => renderSection(tab.key)); } function updateTabUrl(tabKey) { const url = new URL(window.location.href); url.searchParams.set("tab", tabKey); window.history.replaceState( {}, "", `${url.pathname}?${url.searchParams.toString()}${url.hash}`, ); } function renderSection(tabKey) { const tab = config.tabs.find((item) => item.key === tabKey); const container = document.querySelector( `.page-editor-section[data-section-key="${tabKey}"]`, ); if (!tab || !container) return; container.innerHTML = ""; renderField(tab.schema, container, state, tab.schema.key, tabKey, { path: tab.schema.key, root: state, item: null, }); } function renderField(schema, container, parent, key, tabKey, context) { if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) { return; } if (schema.type === "object") { if (!isObject(parent[key])) { parent[key] = {}; } const groupWrapper = document.createElement("div"); groupWrapper.className = "row g-3"; container.appendChild(groupWrapper); (schema.fields || []).forEach((field) => { renderField(field, groupWrapper, parent[key], field.key, tabKey, { path: appendPath(context.path, field.key), root: context.root, item: parent[key], }); }); return; } if (schema.type === "array") { if (!Array.isArray(parent[key])) { parent[key] = []; } applyAutoSequenceToArray(schema, parent[key]); const col = createCol(schema.colClass || "col-12"); const card = document.createElement("div"); card.className = "border rounded-3 bg-light-subtle p-3"; const header = document.createElement("div"); header.className = "d-flex justify-content-between align-items-center mb-3 gap-3"; header.innerHTML = `
${schema.helpText ? `
${escapeHtml(schema.helpText)}
` : ""}
`; header.querySelector("button").addEventListener("click", function () { parent[key].push(createDefaultValue(schema.itemSchema)); applyAutoSequenceToArray(schema, parent[key]); renderSection(tabKey); }); card.appendChild(header); if (parent[key].length === 0) { const empty = document.createElement("div"); empty.className = "text-muted small"; empty.textContent = schema.emptyText || `No ${schema.itemLabel || "items"} yet.`; card.appendChild(empty); } else { const list = document.createElement("div"); list.className = "page-editor-array-list"; card.appendChild(list); parent[key].forEach((item, index) => { const itemCard = document.createElement("div"); itemCard.className = "card shadow-sm border-0 mb-3"; itemCard.dataset.index = String(index); const itemHeader = document.createElement("div"); itemHeader.className = "card-header bg-white d-flex justify-content-between align-items-center gap-2 flex-wrap"; const title = getArrayItemTitle(schema, item, index); const subtitle = getArrayItemSubtitle(schema, item); itemHeader.innerHTML = `
${ schema.sortable ? '' : "" }
${escapeHtml(title)}
${subtitle ? `
${escapeHtml(subtitle)}
` : ""}
${renderItemActions(schema.itemActions, item)}
`; itemHeader .querySelector('[data-remove-item="true"]') .addEventListener("click", function () { parent[key].splice(index, 1); applyAutoSequenceToArray(schema, parent[key]); renderSection(tabKey); }); itemHeader.querySelectorAll("[data-item-href]").forEach((actionButton) => { actionButton.addEventListener("click", function () { window.location.href = actionButton.dataset.itemHref; }); }); const itemBody = document.createElement("div"); itemBody.className = "card-body"; if (schema.itemSchema.type === "primitive") { renderPrimitiveArrayItem(schema, itemBody, parent[key], index, tabKey, context); } else if (schema.itemSchema.type === "variant") { renderVariantArrayItem( schema.itemSchema, itemBody, parent[key], index, tabKey, { path: appendPath(context.path, String(index)), root: context.root, item: parent[key][index], }, ); } else { const bodyRow = document.createElement("div"); bodyRow.className = "row g-3"; itemBody.appendChild(bodyRow); (schema.itemSchema.fields || []).forEach((field) => { renderField(field, bodyRow, parent[key][index], field.key, tabKey, { path: appendPath(context.path, `${index}.${field.key}`), root: context.root, item: parent[key][index], }); }); } itemCard.appendChild(itemHeader); itemCard.appendChild(itemBody); list.appendChild(itemCard); }); if (schema.sortable && window.Sortable) { window.Sortable.create(list, { animation: 150, handle: ".drag-handle", onEnd: function (event) { if ( typeof event.oldIndex !== "number" || typeof event.newIndex !== "number" || event.oldIndex === event.newIndex ) { return; } const movedItem = parent[key].splice(event.oldIndex, 1)[0]; parent[key].splice(event.newIndex, 0, movedItem); applyAutoSequenceToArray(schema, parent[key]); renderSection(tabKey); }, }); } } col.appendChild(card); container.appendChild(col); return; } if (schema.type === "checkbox") { renderCheckbox(schema, container, parent, key); return; } renderLeafField(schema, container, parent, key, context); } function renderPrimitiveArrayItem(arraySchema, container, targetArray, index, tabKey, context) { const fieldSchema = arraySchema.itemSchema; const row = document.createElement("div"); row.className = "row g-3"; container.appendChild(row); const holder = { value: targetArray[index] || "" }; renderLeafField( { key: "value", label: fieldSchema.label || arraySchema.itemLabel || "Value", type: fieldSchema.fieldType || "text", maxLength: fieldSchema.maxLength, placeholder: fieldSchema.placeholder, helpText: fieldSchema.helpText, rows: fieldSchema.fieldType === "textarea" ? 3 : undefined, }, row, holder, "value", { path: appendPath(context.path, String(index)), root: context.root, item: holder, }, ); const input = row.querySelector("input, textarea, select"); if (input) { const sync = function () { targetArray[index] = fieldSchema.fieldType === "number" ? Number(holder.value || 0) : holder.value; }; input.addEventListener("input", sync); input.addEventListener("change", sync); } } function renderVariantArrayItem(variantSchema, container, targetArray, index, tabKey, context) { const item = targetArray[index]; if (!isObject(item)) { targetArray[index] = {}; } const currentType = targetArray[index][variantSchema.discriminator] || variantSchema.options[0].value; targetArray[index][variantSchema.discriminator] = currentType; const currentVariant = variantSchema.variants[currentType]; const typeRow = document.createElement("div"); typeRow.className = "row g-3 mb-2"; container.appendChild(typeRow); renderLeafField( { key: variantSchema.discriminator, label: "Section type", type: "select", options: variantSchema.options, }, typeRow, targetArray[index], variantSchema.discriminator, context, ); const selectInput = typeRow.querySelector("select"); if (selectInput) { selectInput.addEventListener("change", function () { const newType = this.value; targetArray[index] = { type: newType }; renderSection(tabKey); }); } if (currentVariant && currentVariant.schema) { const sectionRow = document.createElement("div"); sectionRow.className = "row g-3"; container.appendChild(sectionRow); (currentVariant.schema.fields || []).forEach((field) => { renderField(field, sectionRow, targetArray[index], field.key, tabKey, { path: appendPath(context.path, field.key), root: context.root, item: targetArray[index], }); }); } } function renderLeafField(schema, container, parent, key, context) { if (schema.type === "hidden") { if (typeof parent[key] === "undefined" || parent[key] === null) { parent[key] = schema.defaultValue || ""; } return; } if (typeof parent[key] === "undefined" || parent[key] === null) { parent[key] = schema.type === "number" ? 0 : ""; } const col = createCol(schema.colClass || inferColClass(schema.type)); const label = document.createElement("label"); label.className = "form-label fw-semibold"; label.textContent = schema.label || key; col.appendChild(label); if (schema.type === "textarea") { const textarea = document.createElement("textarea"); textarea.className = "form-control"; textarea.rows = schema.rows || 4; textarea.value = parent[key] || ""; if (schema.placeholder) textarea.placeholder = schema.placeholder; if (schema.maxLength) textarea.maxLength = schema.maxLength; textarea.addEventListener("input", function () { parent[key] = textarea.value; updateCounter(counter, textarea.value.length, schema.maxLength); }); col.appendChild(textarea); const counter = appendHelp(col, schema, parent[key]); container.appendChild(col); return; } if (schema.type === "image") { const group = document.createElement("div"); group.className = "input-group"; const input = document.createElement("input"); input.type = "text"; input.className = "form-control"; input.value = parent[key] || ""; input.addEventListener("input", function () { parent[key] = input.value; preview.src = resolveImageUrl(input.value); preview.classList.toggle("d-none", !input.value); }); const button = document.createElement("button"); button.type = "button"; button.className = "btn btn-outline-primary"; button.innerHTML = 'Upload'; button.addEventListener("click", function () { openImagePicker(schema.imageType || config.imageType, function (path) { parent[key] = path; input.value = path; preview.src = resolveImageUrl(path); preview.classList.toggle("d-none", !path); }); }); group.appendChild(input); group.appendChild(button); col.appendChild(group); const preview = document.createElement("img"); preview.className = "img-thumbnail uploaded-preview mt-2"; preview.style.maxHeight = "200px"; preview.src = resolveImageUrl(parent[key]); preview.classList.toggle("d-none", !parent[key]); col.appendChild(preview); appendHelp(col, schema, parent[key], schema.imageHint); container.appendChild(col); return; } if (schema.type === "icon") { renderIconField(schema, col, parent, key); container.appendChild(col); return; } if (schema.type === "select") { const input = document.createElement("select"); input.className = "form-select"; const options = resolveOptions(schema, context.root); options.forEach((option) => { const optionEl = document.createElement("option"); if (typeof option === "string") { optionEl.value = option; optionEl.textContent = option; } else { optionEl.value = option.value; optionEl.textContent = option.label; } input.appendChild(optionEl); }); input.value = parent[key] || input.options[0]?.value || ""; parent[key] = input.value; input.addEventListener("change", function () { parent[key] = input.value; renderAllSections(); }); col.appendChild(input); appendHelp(col, schema, parent[key]); container.appendChild(col); return; } if (schema.type === "combobox") { const input = document.createElement("input"); const listId = `list-${sanitizeId(context.path)}-${sanitizeId(key)}`; input.className = "form-control"; input.type = "text"; input.value = parent[key] || ""; if (schema.placeholder) input.placeholder = schema.placeholder; if (schema.maxLength) input.maxLength = schema.maxLength; input.setAttribute("list", listId); input.addEventListener("input", function () { parent[key] = input.value; updateCounter(counter, input.value.length, schema.maxLength); }); const dataList = document.createElement("datalist"); dataList.id = listId; resolveOptions(schema, context.root).forEach((option) => { const item = document.createElement("option"); item.value = typeof option === "string" ? option : option.value; item.label = typeof option === "string" ? option : option.label; dataList.appendChild(item); }); col.appendChild(input); col.appendChild(dataList); const counter = appendHelp(col, schema, parent[key]); container.appendChild(col); return; } const input = document.createElement("input"); input.className = "form-control"; input.type = schema.type === "number" || schema.type === "color" ? schema.type : "text"; input.value = parent[key] || ""; if (schema.placeholder) input.placeholder = schema.placeholder; if (schema.maxLength) input.maxLength = schema.maxLength; if (schema.step) input.step = schema.step; input.addEventListener("input", function () { parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value; updateCounter(counter, String(input.value || "").length, schema.maxLength); }); col.appendChild(input); const counter = appendHelp(col, schema, parent[key]); container.appendChild(col); } function renderIconField(schema, col, parent, key) { const selected = parent[key] || ""; const wrapper = document.createElement("div"); wrapper.className = "border rounded-3 bg-white p-3"; const top = document.createElement("div"); top.className = "d-flex gap-2 align-items-center mb-3"; const input = document.createElement("input"); input.type = "text"; input.className = "form-control"; input.value = selected; input.placeholder = "fa-shield-check"; input.setAttribute("list", "cms-icon-options"); top.appendChild(input); const preview = document.createElement("div"); preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center"; preview.style.width = "52px"; preview.innerHTML = selected ? `` : '--'; top.appendChild(preview); wrapper.appendChild(top); const grid = document.createElement("div"); grid.className = "d-flex flex-wrap gap-2"; (schema.options || []).forEach((option) => { const button = document.createElement("button"); button.type = "button"; button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`; button.innerHTML = `${escapeHtml(option)}`; button.addEventListener("click", function () { parent[key] = option; input.value = option; preview.innerHTML = ``; renderAllSections(); }); grid.appendChild(button); }); wrapper.appendChild(grid); input.addEventListener("input", function () { parent[key] = input.value; preview.innerHTML = input.value ? `` : '--'; }); col.appendChild(wrapper); appendHelp(col, schema, parent[key]); } function renderCheckbox(schema, container, parent, key) { if (typeof parent[key] !== "boolean") { parent[key] = Boolean(parent[key]); } const col = createCol(schema.colClass || "col-12"); const wrapper = document.createElement("div"); wrapper.className = "form-check mt-4"; const input = document.createElement("input"); input.type = "checkbox"; input.className = "form-check-input"; input.checked = parent[key]; input.addEventListener("change", function () { parent[key] = input.checked; }); const label = document.createElement("label"); label.className = "form-check-label fw-semibold"; label.textContent = schema.label || key; wrapper.appendChild(input); wrapper.appendChild(label); col.appendChild(wrapper); if (schema.helpText) { const help = document.createElement("div"); help.className = "form-text"; help.textContent = schema.helpText; col.appendChild(help); } container.appendChild(col); } function appendHelp(col, schema, value, extraHint) { const wrapper = document.createElement("div"); wrapper.className = "d-flex justify-content-between gap-3"; const help = document.createElement("div"); help.className = "form-text"; help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" "); wrapper.appendChild(help); let counter = null; if (schema.maxLength) { counter = document.createElement("div"); counter.className = "form-text text-end ms-auto"; updateCounter(counter, String(value || "").length, schema.maxLength); wrapper.appendChild(counter); } if (help.textContent || counter) { col.appendChild(wrapper); } return counter; } function updateCounter(counter, currentLength, maxLength) { if (!counter || !maxLength) return; counter.textContent = `${currentLength}/${maxLength}`; } function openImagePicker(imageType, onSuccess) { const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.accept = "image/*"; fileInput.style.display = "none"; document.body.appendChild(fileInput); fileInput.addEventListener("change", async function () { if (!fileInput.files || !fileInput.files[0]) { fileInput.remove(); return; } try { const formData = new FormData(); formData.append("image", fileInput.files[0]); const response = await fetch( `/admin/upload/image?imageType=${encodeURIComponent(imageType)}`, { method: "POST", body: formData, }, ); const result = await response.json(); if (!result.success || !result.path) { throw new Error(result.error || "Upload failed"); } onSuccess(result.path); showToast("Success", "Image uploaded successfully", "success"); } catch (error) { showToast("Error", error.message || "Upload failed", "danger"); } finally { fileInput.remove(); } }); fileInput.click(); } function showToast(title, message, type) { const container = document.querySelector(".toast-container") || createToastContainer(); const toast = document.createElement("div"); toast.className = `toast align-items-center text-white bg-${type || "info"} border-0`; toast.setAttribute("role", "alert"); toast.innerHTML = `
${escapeHtml( title, )}: ${escapeHtml(message)}
`; 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 ``; }) .join(""); } function passesVisibility(condition, parent, context) { if (!condition || !condition.path) return true; const target = condition.path === "$item" ? context.item : getValueByPath(parent, condition.path) ?? getValueByPath(context.item, condition.path) ?? getValueByPath(context.root, condition.path); if (Array.isArray(condition.equals)) { return condition.equals.includes(target); } return target === condition.equals; } function appendPath(basePath, segment) { return basePath ? `${basePath}.${segment}` : segment; } function getValueByPath(target, path) { if (!target || !path) return undefined; return String(path) .split(".") .reduce((current, segment) => { if (current === null || typeof current === "undefined") return undefined; return current[segment]; }, target); } function fillTemplate(template, item) { if (!template) return ""; return template.replace(/\{([^}]+)\}/g, function (_, key) { return item[key] || ""; }); } function sanitizeId(value) { return String(value || "") .replace(/[^a-zA-Z0-9_-]+/g, "-") .replace(/^-+|-+$/g, ""); } function isObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function escapeHtml(value) { return String(value || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } })();