feat(cms): enhance content editors and implement automatic ID generation

Improve the CMS administration interface across multiple pages (Accreditation, Admissions, History, Partnerships, and Policies) with a focus on usability and data integrity.
Key changes include:
- Implement `ensureUniqueIds` utility to automatically generate and maintain unique slugs for content items, removing the need for manual ID entry in the UI.
- Refactor the Admissions calculator to support detailed per-option editing via a new dedicated view and routes.
- Replace basic datalists with a custom, searchable icon combobox component for better visual selection.
- Update `_renderSingletonPageView` to handle active tab persistence via query parameters.
- Streamline editor configurations by removing redundant fields and improving help text.
- Enhance the Admissions "Key Dates" editor with a dynamic table interface for managing columns and rows.
- Normalize data payloads in controllers to ensure consistent API responses and internal linking.
This commit is contained in:
Tống Thành Đạt
2026-04-21 12:37:19 +07:00
parent 122df1a46e
commit 5d0fae6d51
30 changed files with 2174 additions and 477 deletions
-1
View File
@@ -31,7 +31,6 @@
</div>
<div class="tab-content">
<%- include("partials/trust-banner-tab", { activeTab }) %>
<%- include("partials/hero-tab", { activeTab }) %>
<%- include("partials/grid-tab", { activeTab }) %>
</div>
@@ -468,37 +468,46 @@
renderAllSections();
});
col.appendChild(input);
appendHelp(col, schema, parent[key]);
appendHelp(col, { ...schema, maxLength: undefined }, 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 input = document.createElement("select");
input.className = "form-select";
const options = resolveOptions(schema, context.root);
const optionValues = options.map((option) =>
typeof option === "string" ? option : option.value,
);
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);
});
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);
if (parent[key] && !optionValues.includes(parent[key])) {
const legacyOption = document.createElement("option");
legacyOption.value = parent[key];
legacyOption.textContent = parent[key];
input.appendChild(legacyOption);
}
input.value = parent[key] || input.options[0]?.value || "";
parent[key] = input.value;
input.addEventListener("change", function () {
parent[key] = input.value;
});
col.appendChild(input);
col.appendChild(dataList);
const counter = appendHelp(col, schema, parent[key]);
appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
container.appendChild(col);
return;
}
@@ -524,52 +533,113 @@
function renderIconField(schema, col, parent, key) {
const selected = parent[key] || "";
const wrapper = document.createElement("div");
wrapper.className = "border rounded-3 bg-white p-3";
wrapper.className = "cms-icon-combobox";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "cms-icon-dropdown-trigger";
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 triggerValue = document.createElement("div");
triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
preview.style.width = "52px";
preview.innerHTML = selected
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
: '<span class="text-muted small">--</span>';
top.appendChild(preview);
wrapper.appendChild(top);
preview.className = "cms-icon-preview";
triggerValue.appendChild(preview);
const grid = document.createElement("div");
grid.className = "d-flex flex-wrap gap-2";
(schema.options || []).forEach((option) => {
const button = document.createElement("button");
button.type = "button";
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
button.addEventListener("click", function () {
parent[key] = option;
input.value = option;
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
renderAllSections();
});
grid.appendChild(button);
});
wrapper.appendChild(grid);
const triggerText = document.createElement("div");
triggerText.className = "cms-icon-dropdown-text";
triggerValue.appendChild(triggerText);
trigger.appendChild(triggerValue);
input.addEventListener("input", function () {
parent[key] = input.value;
preview.innerHTML = input.value
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
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]);
}
@@ -607,7 +677,7 @@
container.appendChild(col);
}
function appendHelp(col, schema, value, extraHint) {
function appendHelp(col, schema, value, extraHint, providedCounter) {
const wrapper = document.createElement("div");
wrapper.className = "field-meta-row";
@@ -616,11 +686,14 @@
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
wrapper.appendChild(help);
let counter = null;
if (schema.maxLength) {
let counter = providedCounter || null;
if (!counter && schema.maxLength) {
counter = document.createElement("div");
counter.className = "field-char-count";
updateCounter(counter, String(value || "").length, schema.maxLength);
}
if (counter) {
wrapper.appendChild(counter);
}
@@ -883,3 +956,4 @@
})();
</script>
@@ -0,0 +1,244 @@
<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 class="d-flex gap-2">
<a href="/admin/admissions?tab=calculator" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Back to Calculator
</a>
<a href="<%= previewUrl %>" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View Page
</a>
</div>
</div>
<div class="row">
<div class="col-12">
<form action="/admin/admissions/calculator/<%= option.id %>/update" method="POST" class="content-with-fixed-buttons" id="calculatorOptionForm" novalidate>
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-calculator me-2"></i>Option Details</h6>
</div>
<div class="card-body p-4">
<div class="row g-3">
<div class="col-md-6">
<label for="label" class="form-label fw-semibold">Option label</label>
<input id="label" name="label" type="text" class="form-control" maxlength="12" value="<%= option.label %>" required />
<div class="field-meta-row">
<div class="form-text">This label appears in the calculator option switcher.</div>
<div class="field-char-count" data-counter-for="label">0/12</div>
</div>
</div>
<div class="col-md-6">
<label for="paceLabel" class="form-label fw-semibold">Pace label</label>
<input id="paceLabel" name="paceLabel" type="text" class="form-control" maxlength="30" value="<%= option.paceLabel %>" required />
<div class="field-meta-row">
<div class="form-text">This appears above the pace slider.</div>
<div class="field-char-count" data-counter-for="paceLabel">0/30</div>
</div>
</div>
<div class="col-md-6">
<label for="minPaceLabel" class="form-label fw-semibold">Minimum pace label</label>
<input id="minPaceLabel" name="minPaceLabel" type="text" class="form-control" maxlength="20" value="<%= option.minPaceLabel %>" required />
<div class="field-meta-row">
<div class="form-text">This appears on the left side of the pace slider.</div>
<div class="field-char-count" data-counter-for="minPaceLabel">0/20</div>
</div>
</div>
<div class="col-md-6">
<label for="maxPaceLabel" class="form-label fw-semibold">Maximum pace label</label>
<input id="maxPaceLabel" name="maxPaceLabel" type="text" class="form-control" maxlength="20" value="<%= option.maxPaceLabel %>" required />
<div class="field-meta-row">
<div class="form-text">This appears on the right side of the pace slider.</div>
<div class="field-char-count" data-counter-for="maxPaceLabel">0/20</div>
</div>
</div>
<div class="col-md-6">
<label for="resultLabel" class="form-label fw-semibold">Result label</label>
<input id="resultLabel" name="resultLabel" type="text" class="form-control" maxlength="40" value="<%= option.resultLabel %>" required />
<div class="field-meta-row">
<div class="form-text">This label appears above the calculated amount.</div>
<div class="field-char-count" data-counter-for="resultLabel">0/40</div>
</div>
</div>
<div class="col-md-6">
<label for="monthlyAmount" class="form-label fw-semibold">Monthly amount</label>
<input id="monthlyAmount" name="monthlyAmount" type="number" class="form-control" min="1" step="1" inputmode="numeric" value="<%= option.monthlyAmount %>" required />
<div class="field-meta-row">
<div class="form-text">Enter digits only. The currency symbol is added on the website automatically.</div>
</div>
</div>
<div class="col-md-6">
<label for="monthlySuffix" class="form-label fw-semibold">Monthly suffix</label>
<input id="monthlySuffix" name="monthlySuffix" type="text" class="form-control" maxlength="10" value="<%= option.monthlySuffix %>" required />
<div class="field-meta-row">
<div class="form-text">Example: /mo</div>
<div class="field-char-count" data-counter-for="monthlySuffix">0/10</div>
</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Note icon</label>
<div class="cms-icon-combobox" id="noteIconCombobox">
<input type="hidden" name="noteIcon" id="noteIcon" value="<%= option.noteIcon %>" />
<button type="button" class="cms-icon-dropdown-trigger" id="noteIconTrigger">
<div class="cms-icon-dropdown-value">
<div class="cms-icon-preview" id="noteIconPreview"></div>
<div class="cms-icon-dropdown-text" id="noteIconText"></div>
</div>
<i class="fas fa-chevron-down cms-icon-dropdown-caret"></i>
</button>
<div class="cms-icon-dropdown-panel d-none" id="noteIconPanel">
<input type="text" class="form-control" id="noteIconSearch" placeholder="Search icon name" />
<div class="cms-icon-options" id="noteIconOptions"></div>
<div class="cms-icon-option-empty d-none" id="noteIconEmpty">No matching icons</div>
</div>
</div>
<div class="field-meta-row">
<div class="form-text">Choose the icon shown beside the note.</div>
</div>
</div>
<div class="col-12">
<label for="note" class="form-label fw-semibold">Note text</label>
<input id="note" name="note" type="text" class="form-control" maxlength="60" value="<%= option.note %>" required />
<div class="field-meta-row">
<div class="form-text">This short note appears under the amount.</div>
<div class="field-char-count" data-counter-for="note">0/60</div>
</div>
</div>
</div>
</div>
</div>
<div class="fixed-bottom-buttons">
<a href="/admin/admissions?tab=calculator" class="btn btn-secondary">
<i class="fas fa-times"></i>
<span>Cancel</span>
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
<span>Save Changes</span>
</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function () {
const iconOptions = <%- JSON.stringify(iconOptions) %>;
const form = document.getElementById("calculatorOptionForm");
const hiddenInput = document.getElementById("noteIcon");
const combobox = document.getElementById("noteIconCombobox");
const trigger = document.getElementById("noteIconTrigger");
const panel = document.getElementById("noteIconPanel");
const preview = document.getElementById("noteIconPreview");
const text = document.getElementById("noteIconText");
const search = document.getElementById("noteIconSearch");
const optionsWrap = document.getElementById("noteIconOptions");
const empty = document.getElementById("noteIconEmpty");
form.querySelectorAll("[maxlength]").forEach((input) => {
const counter = form.querySelector(`[data-counter-for="${input.id}"]`);
const updateCounter = () => {
if (counter) {
counter.textContent = `${input.value.length}/${input.maxLength}`;
}
};
input.addEventListener("input", updateCounter);
updateCounter();
});
const monthlyAmountInput = document.getElementById("monthlyAmount");
monthlyAmountInput.addEventListener("input", function () {
this.value = this.value.replace(/[^\d]/g, "");
});
monthlyAmountInput.addEventListener("change", function () {
const numericValue = Number(this.value);
this.value = numericValue > 0 ? String(Math.floor(numericValue)) : "1";
});
function updateTrigger(value) {
preview.innerHTML = value
? `<i class="fa-solid ${escapeHtml(value)}"></i>`
: '<span class="text-muted small">--</span>';
text.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>';
}
function setOpen(isOpen) {
combobox.classList.toggle("is-open", isOpen);
panel.classList.toggle("d-none", !isOpen);
if (isOpen) {
search.focus();
search.select();
}
}
function renderOptions(term) {
const query = String(term || "").trim().toLowerCase();
const filtered = iconOptions.filter((option) => option.toLowerCase().includes(query));
optionsWrap.innerHTML = "";
empty.classList.toggle("d-none", filtered.length > 0);
filtered.forEach((option) => {
const button = document.createElement("button");
button.type = "button";
button.className = `cms-icon-option ${hiddenInput.value === option ? "is-active" : ""}`;
button.innerHTML = `<span class="cms-icon-option-main"><i class="fa-solid ${escapeHtml(option)}"></i><span>${escapeHtml(option)}</span></span>${hiddenInput.value === option ? '<i class="fas fa-check small"></i>' : ""}`;
button.addEventListener("click", function () {
hiddenInput.value = option;
search.value = option;
updateTrigger(option);
renderOptions(option);
setOpen(false);
});
optionsWrap.appendChild(button);
});
}
trigger.addEventListener("click", function () {
const shouldOpen = panel.classList.contains("d-none");
setOpen(shouldOpen);
if (shouldOpen) {
renderOptions(search.value || hiddenInput.value);
}
});
search.addEventListener("input", function () {
renderOptions(search.value);
});
combobox.addEventListener("focusout", function () {
window.setTimeout(function () {
if (!combobox.contains(document.activeElement)) {
setOpen(false);
}
}, 0);
});
search.value = hiddenInput.value;
updateTrigger(hiddenInput.value);
renderOptions(hiddenInput.value);
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
})();
</script>
+624 -39
View File
@@ -12,6 +12,11 @@
}
const state = JSON.parse(JSON.stringify(initialData));
const persistedCalculatorOptionIds = new Set(
Array.isArray(initialData?.calculator?.options)
? initialData.calculator.options.map((item) => item.id).filter(Boolean)
: [],
);
const iconOptions = Array.from(
new Set(
(config.tabs || [])
@@ -71,6 +76,17 @@
if (!tab || !container) return;
container.innerHTML = "";
if (tabKey === "keyDates") {
renderKeyDatesSection(container);
return;
}
if (tabKey === "calculator") {
renderCalculatorSection(container);
return;
}
renderField(tab.schema, container, state, tab.schema.key, tabKey, {
path: tab.schema.key,
root: state,
@@ -78,6 +94,464 @@
});
}
function renderKeyDatesSection(container) {
normalizeKeyDatesState();
const keyDates = state.keyDates;
const row = document.createElement("div");
row.className = "row g-3";
container.appendChild(row);
renderLeafField(
{ key: "title", label: "Section title", type: "text", maxLength: 60 },
row,
keyDates,
"title",
{ path: "keyDates.title", root: state, item: keyDates },
);
const tableCol = createCol("col-12");
const card = document.createElement("div");
card.className = "cms-editor-group";
const header = document.createElement("div");
header.className = "d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3";
header.innerHTML = `
<div>
<label class="form-label fw-semibold mb-1">Key dates table</label>
<div class="form-text mt-0">Manage the table directly by adding or removing columns and rows.</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-column="true">
<i class="fas fa-table-columns me-1"></i>Add Column
</button>
<button type="button" class="btn btn-outline-primary btn-sm" data-add-row="true">
<i class="fas fa-plus me-1"></i>Add Row
</button>
</div>
`;
card.appendChild(header);
const tableWrap = document.createElement("div");
tableWrap.className = "table-responsive";
const table = document.createElement("table");
table.className = "table align-middle mb-0";
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
keyDates.columns.forEach((column, columnIndex) => {
const th = document.createElement("th");
th.style.minWidth = "220px";
const group = document.createElement("div");
group.className = "d-flex align-items-start gap-2";
const input = document.createElement("input");
input.type = "text";
input.className = "form-control";
input.maxLength = 40;
input.placeholder = "Column name";
input.value = column.label || "";
input.addEventListener("input", function () {
column.label = input.value;
});
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "cms-remove-button mt-1";
removeButton.title = "Remove column";
removeButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeButton.disabled = keyDates.columns.length <= 1;
removeButton.addEventListener("click", function () {
if (keyDates.columns.length <= 1) {
return;
}
keyDates.columns.splice(columnIndex, 1);
keyDates.rows.forEach((rowItem) => {
rowItem.cells.splice(columnIndex, 1);
});
renderSection("keyDates");
});
group.appendChild(input);
group.appendChild(removeButton);
th.appendChild(group);
headRow.appendChild(th);
});
const actionHead = document.createElement("th");
actionHead.className = "text-end";
actionHead.style.width = "72px";
actionHead.textContent = "Actions";
headRow.appendChild(actionHead);
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
if (keyDates.rows.length === 0) {
const emptyRow = document.createElement("tr");
const emptyCell = document.createElement("td");
emptyCell.colSpan = keyDates.columns.length + 1;
emptyCell.className = "text-center text-muted py-4";
emptyCell.textContent = "No rows yet.";
emptyRow.appendChild(emptyCell);
tbody.appendChild(emptyRow);
} else {
keyDates.rows.forEach((rowItem, rowIndex) => {
const tr = document.createElement("tr");
keyDates.columns.forEach((column, columnIndex) => {
const td = document.createElement("td");
const input = document.createElement("input");
input.type = "text";
input.className = "form-control";
input.maxLength = 60;
input.placeholder = column.label || `Column ${columnIndex + 1}`;
input.value = rowItem.cells[columnIndex] || "";
input.addEventListener("input", function () {
rowItem.cells[columnIndex] = input.value;
});
td.appendChild(input);
tr.appendChild(td);
});
const actionCell = document.createElement("td");
actionCell.className = "text-end";
const removeRowButton = document.createElement("button");
removeRowButton.type = "button";
removeRowButton.className = "cms-remove-button";
removeRowButton.title = "Remove row";
removeRowButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeRowButton.addEventListener("click", function () {
keyDates.rows.splice(rowIndex, 1);
renderSection("keyDates");
});
actionCell.appendChild(removeRowButton);
tr.appendChild(actionCell);
tbody.appendChild(tr);
});
}
table.appendChild(tbody);
tableWrap.appendChild(table);
card.appendChild(tableWrap);
tableCol.appendChild(card);
container.appendChild(tableCol);
header.querySelector('[data-add-column="true"]').addEventListener("click", function () {
addKeyDatesColumn();
renderSection("keyDates");
});
header.querySelector('[data-add-row="true"]').addEventListener("click", function () {
addKeyDatesRow();
renderSection("keyDates");
});
}
function normalizeKeyDatesState() {
if (!isObject(state.keyDates)) {
state.keyDates = {};
}
const keyDates = state.keyDates;
if (typeof keyDates.id !== "string") {
keyDates.id = "";
}
if (typeof keyDates.title !== "string") {
keyDates.title = "";
}
const legacyColumns = Array.isArray(keyDates.columns) ? keyDates.columns : [];
const legacyRows = Array.isArray(keyDates.rows) ? keyDates.rows : [];
keyDates.columns = normalizeKeyDatesColumns(legacyColumns, legacyRows);
keyDates.rows = normalizeKeyDatesRows(legacyRows, keyDates.columns);
}
function normalizeKeyDatesColumns(columns, rows) {
if (Array.isArray(columns) && columns.length > 0) {
return columns.map((column, index) => {
if (isObject(column)) {
return {
id: column.id || `column-${index + 1}`,
label: column.label || `Column ${index + 1}`,
};
}
return {
id: sanitizeId(column) || `column-${index + 1}`,
label: String(column || `Column ${index + 1}`),
};
});
}
const defaultLabels = extractLegacyKeyDateLabels(rows);
return defaultLabels.map((label, index) => ({
id: sanitizeId(label) || `column-${index + 1}`,
label,
}));
}
function normalizeKeyDatesRows(rows, columns) {
if (!Array.isArray(rows)) {
return [];
}
return rows.map((row, rowIndex) => {
if (isObject(row) && Array.isArray(row.cells)) {
return {
id: row.id || `row-${rowIndex + 1}`,
cells: columns.map((_, columnIndex) => String(row.cells[columnIndex] || "")),
};
}
return {
id: isObject(row) && row.id ? row.id : `row-${rowIndex + 1}`,
cells: columns.map((column, columnIndex) =>
extractLegacyKeyDateCell(row, column, columnIndex),
),
};
});
}
function extractLegacyKeyDateLabels(rows) {
const defaultLabels = ["Term", "Application Deadline", "Classes Start"];
if (!Array.isArray(rows) || rows.length === 0 || !isObject(rows[0])) {
return defaultLabels;
}
const row = rows[0];
if ("term" in row || "applicationDeadline" in row || "classesStart" in row) {
return defaultLabels;
}
const keys = Object.keys(row).filter((key) => key !== "id");
return keys.length ? keys : defaultLabels;
}
function extractLegacyKeyDateCell(row, column, columnIndex) {
if (!isObject(row)) {
return "";
}
const legacyKeys = ["term", "applicationDeadline", "classesStart"];
const legacyKey = legacyKeys[columnIndex];
if (legacyKey && typeof row[legacyKey] !== "undefined") {
return String(row[legacyKey] || "");
}
if (column && column.id && typeof row[column.id] !== "undefined") {
return String(row[column.id] || "");
}
return "";
}
function addKeyDatesColumn() {
normalizeKeyDatesState();
const keyDates = state.keyDates;
const nextIndex = keyDates.columns.length + 1;
keyDates.columns.push({
id: `column-${nextIndex}`,
label: `Column ${nextIndex}`,
});
keyDates.rows.forEach((row) => {
row.cells.push("");
});
}
function addKeyDatesRow() {
normalizeKeyDatesState();
const keyDates = state.keyDates;
keyDates.rows.push({
id: `row-${Date.now()}`,
cells: keyDates.columns.map(() => ""),
});
}
function normalizeCalculatorState() {
if (!isObject(state.calculator)) {
state.calculator = {};
}
const calculator = state.calculator;
calculator.title = String(calculator.title || "");
calculator.description = String(calculator.description || "");
calculator.cta = {
label: String(calculator?.cta?.label || "").slice(0, 15),
href: String(calculator?.cta?.href || ""),
};
const rawOptions = Array.isArray(calculator.options)
? calculator.options
: Array.isArray(calculator.modelOptions)
? calculator.modelOptions.map((label) => ({ label }))
: [];
calculator.options = rawOptions.slice(0, 3).map((option, index) => ({
id: String(option?.id || sanitizeId(option?.label || `option-${index + 1}`) || `option-${index + 1}`),
label: String(option?.label || `Option ${index + 1}`).slice(0, 12),
paceLabel: String(option?.paceLabel || "Target Pace"),
minPaceLabel: String(option?.minPaceLabel || "Relaxed"),
maxPaceLabel: String(option?.maxPaceLabel || "Accelerated"),
resultLabel: String(option?.resultLabel || "Estimated Monthly Payment"),
monthlyAmount: (((String(option?.monthlyAmount || "299").match(/\d[\d,]*/) || [])[0] || "299").replace(/,/g, "")),
monthlySuffix: String(option?.monthlySuffix || "/mo"),
noteIcon: String(option?.noteIcon || "fa-bolt"),
note: String(option?.note || ""),
}));
delete calculator.modelOptions;
delete calculator.paceLabel;
delete calculator.minPaceLabel;
delete calculator.maxPaceLabel;
delete calculator.resultLabel;
delete calculator.monthlyAmount;
delete calculator.monthlySuffix;
delete calculator.noteIcon;
delete calculator.note;
}
function renderCalculatorSection(container) {
normalizeCalculatorState();
const calculator = state.calculator;
const row = document.createElement("div");
row.className = "row g-3";
container.appendChild(row);
renderLeafField(
{ key: "title", label: "Card title", type: "text", maxLength: 60 },
row,
calculator,
"title",
{ path: "calculator.title", root: state, item: calculator },
);
renderLeafField(
{ key: "description", label: "Card description", type: "textarea", maxLength: 120, rows: 3 },
row,
calculator,
"description",
{ path: "calculator.description", root: state, item: calculator },
);
const ctaCardCol = createCol("col-12");
const ctaCard = document.createElement("div");
ctaCard.className = "cms-editor-group";
const ctaHeader = document.createElement("div");
ctaHeader.className = "mb-3";
ctaHeader.innerHTML = `
<label class="form-label fw-semibold mb-1">Primary button</label>
<div class="form-text mt-0">This button appears at the bottom of the calculator card.</div>
`;
ctaCard.appendChild(ctaHeader);
const ctaRow = document.createElement("div");
ctaRow.className = "row g-3";
ctaCard.appendChild(ctaRow);
renderLeafField(
{ key: "label", label: "Button label", type: "text", maxLength: 15 },
ctaRow,
calculator.cta,
"label",
{ path: "calculator.cta.label", root: state, item: calculator.cta },
);
renderLeafField(
{ key: "href", label: "Button URL", type: "text", maxLength: 255 },
ctaRow,
calculator.cta,
"href",
{ path: "calculator.cta.href", root: state, item: calculator.cta },
);
ctaCardCol.appendChild(ctaCard);
container.appendChild(ctaCardCol);
const optionsCol = createCol("col-12");
const optionsCard = document.createElement("div");
optionsCard.className = "cms-editor-group";
const optionsHeader = document.createElement("div");
optionsHeader.className = "mb-3";
optionsHeader.innerHTML = `
<label class="form-label fw-semibold mb-1">Calculator options</label>
<div class="form-text mt-0">Each option has its own pricing labels, amount, note, and icon. Open the edit page to update the option details.</div>
`;
optionsCard.appendChild(optionsHeader);
const list = document.createElement("div");
list.className = "d-flex flex-column gap-3";
optionsCard.appendChild(list);
if (!calculator.options.length) {
const empty = document.createElement("div");
empty.className = "text-muted small";
empty.textContent = "No calculator options yet.";
list.appendChild(empty);
} else {
calculator.options.forEach((option, index) => {
const item = document.createElement("div");
item.className = "card cms-item-card";
item.innerHTML = `
<div class="card-header d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<div class="fw-semibold">${escapeHtml(option.label || `Option ${index + 1}`)}</div>
<div class="small text-muted">${escapeHtml(option.monthlyAmount)}${escapeHtml(option.monthlySuffix || "")}</div>
</div>
<div class="d-flex align-items-center gap-2">
${
persistedCalculatorOptionIds.has(option.id)
? `<a href="/admin/admissions/calculator/${encodeURIComponent(option.id)}" class="btn btn-outline-primary btn-sm"><i class="fas fa-pen me-1"></i>Edit option</a>`
: `<button type="button" class="btn btn-outline-secondary btn-sm" disabled><i class="fas fa-save me-1"></i>Save first</button>`
}
<button type="button" class="cms-remove-button" data-remove-option="${index}" title="Remove option">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
`;
item
.querySelector(`[data-remove-option="${index}"]`)
.addEventListener("click", function () {
calculator.options.splice(index, 1);
renderSection("calculator");
});
list.appendChild(item);
});
}
if (calculator.options.length < 3) {
const addButton = document.createElement("button");
addButton.type = "button";
addButton.className = "cms-add-button mt-3";
addButton.innerHTML = '<i class="fas fa-plus me-2"></i>Add calculator option';
addButton.addEventListener("click", function () {
const nextIndex = calculator.options.length + 1;
calculator.options.push({
id: `option-${Date.now()}`,
label: `Option ${nextIndex}`.slice(0, 12),
paceLabel: "Target Pace",
minPaceLabel: "Relaxed",
maxPaceLabel: "Accelerated",
resultLabel: "Estimated Monthly Payment",
monthlyAmount: "299",
monthlySuffix: "/mo",
noteIcon: "fa-bolt",
note: "",
});
renderSection("calculator");
});
optionsCard.appendChild(addButton);
} else {
const limitNote = document.createElement("div");
limitNote.className = "form-text mt-3";
limitNote.textContent = "You can add up to 3 calculator options.";
optionsCard.appendChild(limitNote);
}
optionsCol.appendChild(optionsCard);
container.appendChild(optionsCol);
}
function renderField(schema, container, parent, key, tabKey, context) {
if (schema.visibleWhen && !passesVisibility(schema.visibleWhen, parent, context)) {
return;
@@ -283,6 +757,9 @@
label: fieldSchema.label || arraySchema.itemLabel || "Value",
type: fieldSchema.fieldType || "text",
maxLength: fieldSchema.maxLength,
min: fieldSchema.min,
max: fieldSchema.max,
step: fieldSchema.step,
placeholder: fieldSchema.placeholder,
helpText: fieldSchema.helpText,
rows: fieldSchema.fieldType === "textarea" ? 3 : undefined,
@@ -510,11 +987,28 @@
input.value = parent[key] || "";
if (schema.placeholder) input.placeholder = schema.placeholder;
if (schema.maxLength) input.maxLength = schema.maxLength;
if (typeof schema.min !== "undefined") input.min = String(schema.min);
if (typeof schema.max !== "undefined") input.max = String(schema.max);
if (schema.step) input.step = schema.step;
input.addEventListener("input", function () {
parent[key] = schema.type === "number" ? Number(input.value || 0) : input.value;
if (schema.type === "number") {
const normalizedValue = normalizeNumberInput(schema, input.value);
input.value = normalizedValue.displayValue;
parent[key] = normalizedValue.numericValue;
} else {
parent[key] = input.value;
}
updateCounter(counter, String(input.value || "").length, schema.maxLength);
});
input.addEventListener("change", function () {
if (schema.type !== "number") {
return;
}
const normalizedValue = normalizeNumberInput(schema, input.value);
input.value = normalizedValue.displayValue;
parent[key] = normalizedValue.numericValue;
});
col.appendChild(input);
const counter = appendHelp(col, schema, parent[key]);
@@ -524,52 +1018,113 @@
function renderIconField(schema, col, parent, key) {
const selected = parent[key] || "";
const wrapper = document.createElement("div");
wrapper.className = "border rounded-3 bg-white p-3";
wrapper.className = "cms-icon-combobox";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "cms-icon-dropdown-trigger";
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 triggerValue = document.createElement("div");
triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
preview.style.width = "52px";
preview.innerHTML = selected
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
: '<span class="text-muted small">--</span>';
top.appendChild(preview);
wrapper.appendChild(top);
preview.className = "cms-icon-preview";
triggerValue.appendChild(preview);
const grid = document.createElement("div");
grid.className = "d-flex flex-wrap gap-2";
(schema.options || []).forEach((option) => {
const button = document.createElement("button");
button.type = "button";
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
button.addEventListener("click", function () {
parent[key] = option;
input.value = option;
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
renderAllSections();
});
grid.appendChild(button);
});
wrapper.appendChild(grid);
const triggerText = document.createElement("div");
triggerText.className = "cms-icon-dropdown-text";
triggerValue.appendChild(triggerText);
trigger.appendChild(triggerValue);
input.addEventListener("input", function () {
parent[key] = input.value;
preview.innerHTML = input.value
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
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]);
}
@@ -636,6 +1191,35 @@
counter.textContent = `${currentLength}/${maxLength}`;
}
function normalizeNumberInput(schema, rawValue) {
if (rawValue === "" || rawValue === null || typeof rawValue === "undefined") {
const emptyFallback =
typeof schema.min !== "undefined" ? Number(schema.min) : 0;
return {
numericValue: emptyFallback,
displayValue: String(emptyFallback),
};
}
let numericValue = Number(rawValue);
if (!Number.isFinite(numericValue)) {
numericValue = typeof schema.min !== "undefined" ? Number(schema.min) : 0;
}
if (typeof schema.min !== "undefined" && numericValue < Number(schema.min)) {
numericValue = Number(schema.min);
}
if (typeof schema.max !== "undefined" && numericValue > Number(schema.max)) {
numericValue = Number(schema.max);
}
return {
numericValue,
displayValue: String(numericValue),
};
}
function openImagePicker(imageType, onSuccess) {
const fileInput = document.createElement("input");
fileInput.type = "file";
@@ -883,3 +1467,4 @@
})();
</script>
+136 -62
View File
@@ -468,37 +468,46 @@
renderAllSections();
});
col.appendChild(input);
appendHelp(col, schema, parent[key]);
appendHelp(col, { ...schema, maxLength: undefined }, 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 input = document.createElement("select");
input.className = "form-select";
const options = resolveOptions(schema, context.root);
const optionValues = options.map((option) =>
typeof option === "string" ? option : option.value,
);
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);
});
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);
if (parent[key] && !optionValues.includes(parent[key])) {
const legacyOption = document.createElement("option");
legacyOption.value = parent[key];
legacyOption.textContent = parent[key];
input.appendChild(legacyOption);
}
input.value = parent[key] || input.options[0]?.value || "";
parent[key] = input.value;
input.addEventListener("change", function () {
parent[key] = input.value;
});
col.appendChild(input);
col.appendChild(dataList);
const counter = appendHelp(col, schema, parent[key]);
appendHelp(col, { ...schema, maxLength: undefined }, parent[key]);
container.appendChild(col);
return;
}
@@ -524,52 +533,113 @@
function renderIconField(schema, col, parent, key) {
const selected = parent[key] || "";
const wrapper = document.createElement("div");
wrapper.className = "border rounded-3 bg-white p-3";
wrapper.className = "cms-icon-combobox";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "cms-icon-dropdown-trigger";
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 triggerValue = document.createElement("div");
triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
preview.style.width = "52px";
preview.innerHTML = selected
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
: '<span class="text-muted small">--</span>';
top.appendChild(preview);
wrapper.appendChild(top);
preview.className = "cms-icon-preview";
triggerValue.appendChild(preview);
const grid = document.createElement("div");
grid.className = "d-flex flex-wrap gap-2";
(schema.options || []).forEach((option) => {
const button = document.createElement("button");
button.type = "button";
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
button.addEventListener("click", function () {
parent[key] = option;
input.value = option;
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
renderAllSections();
});
grid.appendChild(button);
});
wrapper.appendChild(grid);
const triggerText = document.createElement("div");
triggerText.className = "cms-icon-dropdown-text";
triggerValue.appendChild(triggerText);
trigger.appendChild(triggerValue);
input.addEventListener("input", function () {
parent[key] = input.value;
preview.innerHTML = input.value
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
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]);
}
@@ -607,7 +677,7 @@
container.appendChild(col);
}
function appendHelp(col, schema, value, extraHint) {
function appendHelp(col, schema, value, extraHint, providedCounter) {
const wrapper = document.createElement("div");
wrapper.className = "field-meta-row";
@@ -616,11 +686,14 @@
help.textContent = [schema.helpText, extraHint].filter(Boolean).join(" ");
wrapper.appendChild(help);
let counter = null;
if (schema.maxLength) {
let counter = providedCounter || null;
if (!counter && schema.maxLength) {
counter = document.createElement("div");
counter.className = "field-char-count";
updateCounter(counter, String(value || "").length, schema.maxLength);
}
if (counter) {
wrapper.appendChild(counter);
}
@@ -883,3 +956,4 @@
})();
</script>
@@ -5,14 +5,10 @@
</div>
<div class="card-body p-4">
<div class="row g-3 mb-4">
<div class="col-md-8">
<div class="col-12">
<label class="form-label fw-semibold">Section heading</label>
<input class="form-control" id="directoryHeading" maxlength="70" value="<%= data.directory?.heading || '' %>">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Load more button label</label>
<input class="form-control" id="directoryLoadMoreLabel" maxlength="40" value="<%= data.directory?.loadMoreLabel || '' %>">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Section description</label>
<textarea class="form-control" id="directoryDescription" rows="3" maxlength="180"><%= data.directory?.description || '' %></textarea>
@@ -36,7 +32,7 @@
<div class="mb-3">
<div>
<label class="form-label fw-semibold mb-1">Partners</label>
<div class="form-text mt-0">Use a short unique partner key so card state stays stable.</div>
<div class="form-text mt-0">Each partner card keeps its own open or closed state automatically.</div>
</div>
</div>
<div id="partnersList"></div>
@@ -14,6 +14,12 @@
}
const state = JSON.parse(JSON.stringify(initialData));
const slugifyValue = (value, fallback) =>
String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || fallback;
const templates = {
tab: document.getElementById("directoryTabTemplate"),
partner: document.getElementById("partnerTemplate"),
@@ -21,8 +27,10 @@
inquiryOption: document.getElementById("inquiryOptionTemplate"),
};
ensurePartnershipIds();
bindStaticEvents();
renderAll();
initStaticCounters();
function bindStaticEvents() {
document.querySelectorAll("[data-tab-key]").forEach((tabTrigger) => {
@@ -43,7 +51,6 @@
document.getElementById("addPartnerBtn")?.addEventListener("click", function () {
state.directory.partners.push({
id: "",
name: "",
category: "",
summary: "",
@@ -53,12 +60,12 @@
collabType: "",
benefits: "",
});
ensurePartnershipIds();
renderPartners();
});
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
state.inquiryForm.fields.push({
id: "",
label: "",
placeholder: "",
type: "text",
@@ -66,6 +73,7 @@
required: true,
options: [],
});
ensurePartnershipIds();
renderInquiryFields();
});
@@ -75,6 +83,7 @@
form.addEventListener("submit", function () {
syncStaticFields();
ensurePartnershipIds();
pageJsonInput.value = JSON.stringify(state);
});
@@ -105,7 +114,6 @@
state.directory.heading = getValue("directoryHeading");
state.directory.description = getValue("directoryDescription");
state.directory.loadMoreLabel = getValue("directoryLoadMoreLabel");
state.cta = {
heading: getValue("ctaHeading"),
@@ -114,7 +122,34 @@
};
state.inquiryForm.title = getValue("inquiryTitle");
state.inquiryForm.submitLabel = getValue("inquirySubmitLabel");
}
function ensurePartnershipIds() {
const usedPartnerIds = new Set();
state.directory.partners = (state.directory.partners || []).map((partner, index) => {
let id = String(partner.id || "").trim() || slugifyValue(partner.name, `partner-${index + 1}`);
let suffix = 2;
while (usedPartnerIds.has(id)) {
id = `${slugifyValue(partner.name, "partner")}-${suffix}`;
suffix += 1;
}
usedPartnerIds.add(id);
return { ...partner, id };
});
const usedFieldIds = new Set();
state.inquiryForm.fields = (state.inquiryForm.fields || []).map((field, index) => {
let id =
String(field.id || "").trim() ||
slugifyValue(field.label || field.placeholder, `field-${index + 1}`);
let suffix = 2;
while (usedFieldIds.has(id)) {
id = `${slugifyValue(field.label || field.placeholder, "field")}-${suffix}`;
suffix += 1;
}
usedFieldIds.add(id);
return { ...field, id };
});
}
function renderAll() {
@@ -143,6 +178,7 @@
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
node.classList.toggle("is-collapsed");
});
attachCounters(node);
directoryTabsList.appendChild(node);
});
@@ -210,6 +246,7 @@
node.querySelector("[data-toggle-item]")?.addEventListener("click", function () {
node.classList.toggle("is-collapsed");
});
attachCounters(node);
partnersList.appendChild(node);
});
@@ -304,6 +341,7 @@
field.options.splice(optionIndex, 1);
renderInquiryFields();
});
attachCounters(optionNode);
optionsList.appendChild(optionNode);
});
initSortable(optionsList, field.options, renderInquiryFields, '[data-item="inquiry-option"]');
@@ -311,6 +349,7 @@
toggleOptions();
renderOptions();
attachCounters(node);
inquiryFieldsList.appendChild(node);
});
@@ -339,6 +378,74 @@
return template.content.firstElementChild.cloneNode(true);
}
function initStaticCounters() {
attachCounters(form);
}
function attachCounters(root) {
root.querySelectorAll("input[maxlength], textarea[maxlength]").forEach((input, index) => {
if (input.dataset.counterReady === "true") {
updateInputCounter(input);
return;
}
const counterId =
input.id ||
input.name ||
input.dataset.field ||
input.dataset.optionValue ||
`counter-${index}-${Math.random().toString(36).slice(2, 8)}`;
let counter = root.querySelector(`[data-counter-for="${counterId}"]`);
if (!counter) {
counter = document.createElement("div");
counter.className = "field-char-count";
counter.dataset.counterFor = counterId;
const next = input.nextElementSibling;
if (next && next.classList.contains("form-text")) {
let metaRow = next.nextElementSibling;
if (!metaRow || !metaRow.classList.contains("field-meta-row")) {
metaRow = document.createElement("div");
metaRow.className = "field-meta-row";
next.insertAdjacentElement("afterend", metaRow);
}
metaRow.appendChild(counter);
} else {
const metaRow = document.createElement("div");
metaRow.className = "field-meta-row";
metaRow.appendChild(counter);
input.insertAdjacentElement("afterend", metaRow);
}
}
const sync = function () {
updateInputCounter(input);
};
input.addEventListener("input", sync);
input.addEventListener("change", sync);
input.dataset.counterReady = "true";
updateInputCounter(input);
});
}
function updateInputCounter(input) {
const maxLength = Number(input.getAttribute("maxlength"));
if (!maxLength) return;
const counterId =
input.id || input.name || input.dataset.field || input.dataset.optionValue;
const scope = input.closest("[data-item]") || input.parentElement || form;
let counter = scope.querySelector(`[data-counter-for="${counterId}"]`);
if (!counter) {
counter = form.querySelector(`[data-counter-for="${counterId}"]`);
}
if (!counter) return;
counter.textContent = `${String(input.value || "").length}/${maxLength}`;
}
function getValue(id) {
return document.getElementById(id)?.value || "";
}
@@ -5,14 +5,10 @@
</div>
<div class="card-body p-4">
<div class="row g-3 mb-4">
<div class="col-md-8">
<div class="col-12">
<label class="form-label fw-semibold">Modal title</label>
<input class="form-control" id="inquiryTitle" maxlength="60" value="<%= data.inquiryForm?.title || '' %>">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Submit button label</label>
<input class="form-control" id="inquirySubmitLabel" maxlength="40" value="<%= data.inquiryForm?.submitLabel || '' %>">
</div>
</div>
<div class="cms-editor-group">
@@ -46,11 +46,6 @@
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-semibold">Partner key</label>
<input class="form-control" data-field="id" maxlength="50">
<div class="form-text">Use a short unique key.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Partner name</label>
<input class="form-control" data-field="name" maxlength="90">
@@ -119,10 +114,6 @@
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-semibold">Field key</label>
<input class="form-control" data-field="id" maxlength="40">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Field label</label>
<input class="form-control" data-field="label" maxlength="40">
+100 -38
View File
@@ -524,52 +524,113 @@
function renderIconField(schema, col, parent, key) {
const selected = parent[key] || "";
const wrapper = document.createElement("div");
wrapper.className = "border rounded-3 bg-white p-3";
wrapper.className = "cms-icon-combobox";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "cms-icon-dropdown-trigger";
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 triggerValue = document.createElement("div");
triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
preview.style.width = "52px";
preview.innerHTML = selected
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
: '<span class="text-muted small">--</span>';
top.appendChild(preview);
wrapper.appendChild(top);
preview.className = "cms-icon-preview";
triggerValue.appendChild(preview);
const grid = document.createElement("div");
grid.className = "d-flex flex-wrap gap-2";
(schema.options || []).forEach((option) => {
const button = document.createElement("button");
button.type = "button";
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
button.addEventListener("click", function () {
parent[key] = option;
input.value = option;
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
renderAllSections();
});
grid.appendChild(button);
});
wrapper.appendChild(grid);
const triggerText = document.createElement("div");
triggerText.className = "cms-icon-dropdown-text";
triggerValue.appendChild(triggerText);
trigger.appendChild(triggerValue);
input.addEventListener("input", function () {
parent[key] = input.value;
preview.innerHTML = input.value
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
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]);
}
@@ -883,3 +944,4 @@
})();
</script>
@@ -524,52 +524,113 @@
function renderIconField(schema, col, parent, key) {
const selected = parent[key] || "";
const wrapper = document.createElement("div");
wrapper.className = "border rounded-3 bg-white p-3";
wrapper.className = "cms-icon-combobox";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "cms-icon-dropdown-trigger";
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 triggerValue = document.createElement("div");
triggerValue.className = "cms-icon-dropdown-value";
const preview = document.createElement("div");
preview.className = "border rounded-2 px-3 d-flex align-items-center justify-content-center";
preview.style.width = "52px";
preview.innerHTML = selected
? `<i class="fa-solid ${escapeHtml(selected)}"></i>`
: '<span class="text-muted small">--</span>';
top.appendChild(preview);
wrapper.appendChild(top);
preview.className = "cms-icon-preview";
triggerValue.appendChild(preview);
const grid = document.createElement("div");
grid.className = "d-flex flex-wrap gap-2";
(schema.options || []).forEach((option) => {
const button = document.createElement("button");
button.type = "button";
button.className = `btn btn-sm ${selected === option ? "btn-primary" : "btn-outline-secondary"}`;
button.innerHTML = `<i class="fa-solid ${escapeHtml(option)} me-1"></i>${escapeHtml(option)}`;
button.addEventListener("click", function () {
parent[key] = option;
input.value = option;
preview.innerHTML = `<i class="fa-solid ${escapeHtml(option)}"></i>`;
renderAllSections();
});
grid.appendChild(button);
});
wrapper.appendChild(grid);
const triggerText = document.createElement("div");
triggerText.className = "cms-icon-dropdown-text";
triggerValue.appendChild(triggerText);
trigger.appendChild(triggerValue);
input.addEventListener("input", function () {
parent[key] = input.value;
preview.innerHTML = input.value
? `<i class="fa-solid ${escapeHtml(input.value)}"></i>`
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]);
}
@@ -883,3 +944,4 @@
})();
</script>
+144
View File
@@ -477,6 +477,150 @@
margin-left: auto;
}
.cms-icon-combobox {
position: relative;
}
.cms-icon-dropdown-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 10px;
background: #fff;
padding: 0.75rem;
color: #334155;
text-align: left;
}
.cms-icon-dropdown-trigger:hover {
border-color: rgba(184, 183, 106, 0.55);
background: rgba(248, 250, 252, 0.96);
}
.cms-icon-combobox.is-open .cms-icon-dropdown-trigger {
border-color: rgba(184, 183, 106, 0.65);
box-shadow: 0 0 0 0.2rem rgba(184, 183, 106, 0.12);
}
.cms-icon-dropdown-value {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.cms-icon-dropdown-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.cms-icon-dropdown-text strong,
.cms-icon-dropdown-text span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cms-icon-dropdown-caret {
color: #94a3b8;
transition: transform 0.18s ease;
}
.cms-icon-combobox.is-open .cms-icon-dropdown-caret {
transform: rotate(180deg);
}
.cms-icon-preview {
width: 44px;
min-width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
background: #fff;
color: #334155;
}
.cms-icon-dropdown-panel {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
right: 0;
z-index: 30;
background: #fff;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 10px;
padding: 0.75rem;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
}
.cms-icon-dropdown-panel .form-control {
margin-bottom: 0.75rem;
}
.cms-icon-options {
max-height: 220px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.cms-icon-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
border: 1px solid rgba(148, 163, 184, 0.18);
background: #fff;
color: #334155;
border-radius: 8px;
padding: 0.55rem 0.7rem;
text-align: left;
font-size: 0.875rem;
}
.cms-icon-option-main {
min-width: 0;
display: flex;
align-items: center;
gap: 0.625rem;
}
.cms-icon-option-main span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cms-icon-option:hover {
border-color: rgba(184, 183, 106, 0.55);
background: rgba(248, 250, 252, 0.96);
color: #0f172a;
}
.cms-icon-option.is-active {
border-color: rgba(184, 183, 106, 0.65);
background: rgba(184, 183, 106, 0.12);
color: #0f172a;
}
.cms-icon-option-empty {
color: #94a3b8;
font-size: 0.75rem;
padding: 0.5rem 0.25rem 0;
}
main {
flex: 1;
z-index: 1;