forked from UKSOURCE/cms.lams
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.
245 lines
11 KiB
Plaintext
245 lines
11 KiB
Plaintext
<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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
})();
|
|
</script>
|