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>