/**
* NeoCMS administration client.
*
* The public page is loaded into a same-origin iframe, edited in memory, and only written to disk
* through authenticated API calls. Keeping that distinction explicit prevents a modal's Save
* button from accidentally becoming a rather enthusiastic Publish button.
*/
(function ($) {
'use strict';
// References to the active iframe document and currently selected editable element.
let iframeDoc = null;
let currentElement = null;
let currentImage = null;
// Dirty state protects unpublished browser changes from accidental navigation.
let dirty = false;
// Permissions are replaced by the authoritative dashboard response after initialisation.
let permissions = {draft: true, publish: false, schedule: false, manage: false};
// Remember offered drafts so an iframe rewrite does not prompt for the same draft twice.
const loadedDrafts = new Set();
// True once the page shown has been saved as a server-side draft (cleared by any navigation).
let draftSaved = false;
// Path of the page shown in the preview. Writing a draft into the frame (document.open) makes the frame's own URL
// become the admin page's, so the real page path is captured when the page loads instead.
let framePath = '/';
// Set while a saved draft is written into the preview, so its load event is not treated as navigation.
let writingDraft = false;
// URL of a preview page that was reloaded to bypass the browser cache; its next load is the fresh one.
let revalidatedHref = null;
// Server-rendered values keep selectors and mutating requests aligned with configuration.
const csrfToken = $('meta[name="csrf-token"]').attr('content') || '';
const editableClass = $('meta[name="neo-editable-class"]').attr('content') || 'editable';
const editableSelector = '.' + editableClass;
// A styled box standing in for a photo: role="img" on something other than an or .
const placeholderSelector = '[role="img"]:not(img):not(svg):not([aria-hidden="true"])';
// URL prefix of the site root (empty at a domain root, "/neocms" for http://localhost/neocms/).
const basePath = $('meta[name="neo-base-path"]').attr('content') || '';
// Initialise controls after the administration document is ready.
$(function () {
initialiseDialogs();
bindToolbar();
loadDashboard(false);
$('#frameContainer').on('load', initialiseFrame);
// Browsers display their own standard warning text for beforeunload events.
window.addEventListener('beforeunload', function (event) {
if (dirty) {
event.preventDefault();
event.returnValue = '';
}
});
});
/**
* Call one controller action and convert failed JSON responses into ordinary Error objects.
* POST requests automatically include the session CSRF token.
*/
function api(action, data, method) {
const payload = Object.assign({}, data || {}, {action: action});
if ((method || 'GET') === 'POST') {
payload.csrf_token = csrfToken;
}
return $.ajax({url: basePath + '/cms/controller/', method: method || 'GET', data: payload, dataType: 'json'})
.catch(function (xhr) {
const response = xhr.responseJSON || {};
throw new Error(response.error || 'The CMS request failed');
});
}
/** Latest response from the users action. */
let usersData = null;
/** Configure all jQuery UI dialogues once, leaving individual workflows to populate them. */
function initialiseDialogs() {
$('.cms-dialog, #fileListDialog').hide();
$('.cms-dialog, #fileListDialog').not('#editModal').each(function () {
$(this).dialog({autoOpen: false, modal: true, width: Math.min(760, window.innerWidth - 30)});
});
// The page list is a wide table, so it gets a wider dialogue than the other tools.
$('#fileListDialog').dialog('option', 'width', Math.min(1000, window.innerWidth - 30));
$('#fileListDialog').dialog('option', 'maxHeight', Math.round(window.innerHeight * 0.92));
$('#editModal').dialog({
autoOpen: false,
modal: true,
width: Math.min(900, window.innerWidth - 30),
open: initialiseEditor,
close: destroyEditor
});
}
/** Connect toolbar controls and forms to their workflow handlers. */
function bindToolbar() {
$('#dashboardButton').on('click', function () { loadDashboard(true); });
$('#selectPage').on('click', openPages);
$('#mediaButton').on('click', function () { openMedia(); });
$('#mediaUploadButton').on('click', function () { $('#mediaFile').trigger('click'); });
$('#mediaFile').on('change', function () { const files = Array.from(this.files); this.value = ''; if (files.length) uploadMedia(files); });
$('#seoButton').on('click', openSeo);
$('#moreButton').on('click', function () { $('#toolsDialog').dialog('open'); });
$('#revisionsButton').on('click', openRevisions);
$('#accessibilityButton').on('click', runAccessibilityCheck);
$('#sharedButton').on('click', openShared);
$('#menusButton').on('click', openMenus);
$('#siteScanButton').on('click', openSiteScan);
$('#usersButton').on('click', openUsers);
bindUsers();
$('#siteScanApply').on('click', applySiteScan);
$('#imageForm').on('submit', applyImage);
$('#seoForm').on('submit', applySeo);
$('#scheduleForm').on('submit', schedulePage);
$('#sharedForm').on('submit', saveShared);
$('#menuForm').on('submit', saveMenu);
$('#menuScanButton').on('click', scanForNavigation);
$('#newPageForm').on('submit', createPage);
$('#newPageForm').on('click', '.step-next', function () { stepNewPage(1); });
$('#newPageForm').on('click', '.step-back', function () { stepNewPage(-1); });
$('#pageSearch').on('input', filterPages);
$('#logoutButton').on('click', logout);
$('#closeEditorBtn').on('click', function () { $('#editModal').dialog('close'); });
$('.viewport-button').on('click', function () {
$('#frameContainer').css({width: $(this).data('width'), margin: '0 auto'});
$('.viewport-button').removeClass('active').attr('aria-pressed', 'false');
$(this).addClass('active').attr('aria-pressed', 'true');
});
}
/**
* Bind editing behaviour whenever the preview iframe loads a page.
* Namespaced events are removed first so repeated navigation cannot multiply handlers.
*/
function initialiseFrame() {
const iframe = document.getElementById('frameContainer');
// Static pages carry no cache headers, so a browser may reuse a stale copy (for example after the whole site was
// replaced). Reload each newly opened page once, which revalidates it; skip when edits would be discarded.
const href = iframe.contentWindow.location.href;
// The load caused by writing a saved draft into the frame is not a navigation: keep the draft's content and state.
const draftWrite = writingDraft;
writingDraft = false;
if (!dirty && !draftWrite && href !== 'about:blank' && revalidatedHref !== href) {
revalidatedHref = href;
iframe.contentWindow.location.reload();
return;
}
revalidatedHref = null;
// about:blank is only ever our own draft surface, so it must not reset the page path or draft state.
if (!draftWrite && href !== 'about:blank') {
draftSaved = false;
framePath = iframe.contentWindow.location.pathname;
}
iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
currentElement = null;
$(iframeDoc).off('.neocms');
// Editable regions open TinyMCE; generated block controls retain their own click behaviour.
$(iframeDoc).on('click.neocms', editableSelector, function (event) {
if ($(event.target).closest('.button-container').length) return;
event.preventDefault();
event.stopPropagation();
// Clicking an image inside a region edits just that image; the rest of the region opens the content editor.
const image = $(event.target).closest('img');
const placeholder = image.length ? $() : imagePlaceholder(event.target);
if (image.length) openImageEditor(image);
else if (placeholder.length) openImageEditor(placeholder);
else openEditor($(this));
});
// Internal page navigation receives the same unpublished-change protection as the window.
$(iframeDoc).on('click.neocms', 'a', function (event) {
if (dirty && !window.confirm('Leave this page and discard unpublished changes?')) {
event.preventDefault();
}
});
$(iframeDoc).on('click.neocms', '.duplicate-before, .duplicate-after', duplicateBlock);
$(iframeDoc).on('click.neocms', '.delete-block', deleteBlock);
// Images marked by the site scan open the image dialogue, unless an editable region owns them.
// Editor-only cue that images are clickable; a removable