/** * 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
').prependTo(block); }); } /** * Clone and serialise the complete page after removing administration-only decorations. * * @return {string} A standalone HTML document suitable for drafts or publication. */ function serialisePage() { if (!iframeDoc) throw new Error('No page is loaded'); const clone = iframeDoc.cloneNode(true); $(clone).find('.button-container').remove(); $(clone).find('[data-neo-original-position="static"]').css('position', '').removeAttr('data-neo-original-position'); $(clone).find(editableSelector + ', .neo-dupe').css('cursor', ''); $(clone).find('#neo-editor-style, base[data-neo-base]').remove(); return '\n' + clone.documentElement.outerHTML; } /** Return the public path of the page currently displayed in the iframe. */ function currentUri() { const path = framePath; // Server-side URIs are relative to the site root, so drop the subfolder prefix. return basePath && (path === basePath || path.startsWith(basePath + '/')) ? path.slice(basePath.length) || '/' : path; } /** Save the in-memory page as a private server-side draft; the public page is untouched. */ async function saveDraft(message) { try { await api('saveDraft', {uri: currentUri(), content: serialisePage()}, 'POST'); dirty = false; draftSaved = true; // A draft now exists for this page, so leaving and returning must offer it again. loadedDrafts.delete(currentUri()); updateUrl(); showMessage(message || 'Saved as a draft.' + (permissions.publish ? ' Publish to make it live.' : ''), 'success'); } catch (error) { showMessage(error.message, 'error'); } } /** An edit the author has confirmed (dialogue Save or Apply): mark the page changed and keep it as a draft. */ async function commitEdit(message) { markDirty(); await saveDraft(message); } /** Run the pre-publish accessibility prompt and publish the complete page. */ async function publishPage() { if (!permissions.publish) return false; try { const issues = accessibilityIssues(); if (issues.length && !window.confirm('The accessibility check found ' + issues.length + ' issue(s). Publish anyway?')) return false; const result = await api('save', {uri: currentUri(), content: serialisePage()}, 'POST'); dirty = false; draftSaved = false; updateUrl(); showMessage(result.message, 'success'); addBlockControls(); return true; } catch (error) { showMessage(error.message, 'error'); return false; } } /** Offer to replace the public preview with its most recent saved draft. */ /** * Give a draft the address of the page it belongs to. Writing HTML into the frame makes the frame's URL the admin * page's, so without a base the draft's relative stylesheets, images, and links would resolve inside /cms/ and the * page would render unstyled. The helper is marked so serialisePage() removes it again. */ function withBase(html, pageUrl) { if (/]/i.test(html)) return html; const tag = ''; return /]*>/i.test(html) ? html.replace(/]*>/i, function (head) { return head + tag; }) : tag + html; } async function offerDraft() { const uri = currentUri(); if (loadedDrafts.has(uri) || uri.startsWith('/cms/')) return; loadedDrafts.add(uri); try { const draft = await api('getDraft', {uri: uri}); if (draft.exists && window.confirm('A saved draft exists for this page. Load it?')) { writingDraft = true; const pageUrl = iframeDoc.location.href; iframeDoc.open(); iframeDoc.write(withBase(draft.content, pageUrl)); iframeDoc.close(); // The frame now matches the saved draft, so there is nothing unsaved. dirty = false; draftSaved = true; updateUrl(); } } catch (error) { // A page without an available draft needs no noisy notification. } } /** Open the scheduling dialogue with a sensible default of one hour from now. */ function openSchedule() { const date = new Date(Date.now() + 3600000); $('#publishAt').val(new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16)); $('#scheduleDialog').dialog('open'); } /** Convert the editor's local date to an absolute timestamp and queue the page. */ async function schedulePage(event) { event.preventDefault(); try { const result = await api('schedule', {uri: currentUri(), content: serialisePage(), publish_at: new Date($('#publishAt').val()).toISOString()}, 'POST'); dirty = false; $('#scheduleDialog').dialog('close'); if ($('#editModal').dialog('isOpen')) $('#editModal').dialog('close'); showMessage(result.message, 'success'); } catch (error) { showMessage(error.message, 'error'); } } /** Load searchable page metadata and role-appropriate management controls. */ async function openPages() { try { const pages = await api('getPages'); const body = $('#fileList tbody').empty(); $('#fileList .manage-col').toggle(permissions.manage); $('#pageSearch').val(''); $('#pageListEmpty').prop('hidden', true); pages.forEach(function (page) { const row = $('').attr('data-search', (page.name + ' ' + page.title).toLowerCase()).on('click', function () { page.pending ? openPendingPage(page.url) : navigateTo(page.url); }); const name = $('').append(docIcon()); $('