/** * 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; // 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(); // 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; // 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: '/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'); }); } /** Configure all jQuery UI dialogues once, leaving individual workflows to populate them. */ function initialiseDialogs() { $('.cms-dialog, #newPageDialog, #fileListDialog').hide(); $('.cms-dialog, #newPageDialog, #fileListDialog').not('#editModal').each(function () { $(this).dialog({autoOpen: false, modal: true, width: Math.min(760, window.innerWidth - 30)}); }); $('#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); }); $('#newPage').on('click', openNewPage); $('#selectPage').on('click', openPages); $('#saveDraft').on('click', saveDraft); $('#savePage').on('click', publishPage); $('#scheduleButton').on('click', openSchedule); $('#mediaButton').on('click', openMedia); $('#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); $('#seoForm').on('submit', applySeo); $('#scheduleForm').on('submit', schedulePage); $('#sharedForm').on('submit', saveShared); $('#menuForm').on('submit', saveMenu); $('#newPageForm').on('submit', createPage); $('#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'}); }); } /** * 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'); 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(); 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); addBlockControls(); updateUrl(); offerDraft(); } /** Open TinyMCE with the selected region's inner HTML. */ function openEditor(element) { currentElement = element; $('#editor').val(element.html()); $('#editModal').dialog('open'); } /** Initialise TinyMCE after the dialogue is visible and measurable. */ function initialiseEditor() { tinymce.init({ selector: '#editor', height: 360, branding: false, promotion: false, license_key: 'gpl', plugins: 'preview searchreplace autolink autosave directionality code visualblocks visualchars fullscreen image link media codesample table charmap pagebreak nonbreaking anchor insertdatetime advlist lists wordcount quickbars emoticons help', toolbar: 'undo redo | blocks | bold italic underline | alignleft aligncenter alignright | bullist numlist | link image media table | code preview fullscreen', images_upload_handler: uploadImage, automatic_uploads: true, convert_urls: false }); } /** Remove the editor instance when the dialogue closes. */ function destroyEditor() { const editor = tinymce.get('editor'); if (editor) editor.remove(); } /** * Upload a TinyMCE image with progress reporting and CSRF protection. * * @return {Promise} Resolves to the public image URL expected by TinyMCE. */ function uploadImage(blobInfo, progress) { return new Promise(function (resolve, reject) { const form = new FormData(); form.append('file', blobInfo.blob(), blobInfo.filename()); form.append('csrf_token', csrfToken); $.ajax({ url: '/cms/image_upload.php', method: 'POST', data: form, processData: false, contentType: false, xhr: function () { const xhr = $.ajaxSettings.xhr(); xhr.upload.addEventListener('progress', function (event) { if (event.lengthComputable) progress(event.loaded / event.total * 100); }); return xhr; } }).done(function (response) { resolve(response.location); }) .fail(function (xhr) { reject((xhr.responseJSON || {}).error || 'Upload failed'); }); }); } // Apply modal edits to the iframe only; the page remains unpublished until explicitly saved. $('#saveBtn').on('click', function () { const editor = tinymce.get('editor'); if (editor && currentElement) { currentElement.html(editor.getContent()); markDirty(); } $('#editModal').dialog('close'); }); /** Clone a repeatable block before or after its source, then open the clone for editing. */ function duplicateBlock(event) { event.preventDefault(); event.stopPropagation(); const source = $(this).closest('.neo-dupe'); const clone = source.clone(); clone.find('.button-container').remove(); if ($(this).hasClass('duplicate-before')) clone.insertBefore(source); else clone.insertAfter(source); addBlockControls(); markDirty(); openEditor(clone); } /** Remove a repeatable block after explicit confirmation. */ function deleteBlock(event) { event.preventDefault(); event.stopPropagation(); if (window.confirm('Delete this content block?')) { $(this).closest('.neo-dupe').remove(); markDirty(); } } /** Add transient clone and delete controls to every repeatable block in the preview. */ function addBlockControls() { $(iframeDoc).find('.neo-dupe').each(function () { const block = $(this); block.find('.button-container').remove(); // Relative positioning anchors the absolutely positioned controls to this block. if (block.css('position') === 'static') { block.attr('data-neo-original-position', 'static').css('position', 'relative'); } $('
').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', ''); return '\n' + clone.documentElement.outerHTML; } /** Return the public path of the page currently displayed in the iframe. */ function currentUri() { return iframeDoc ? iframeDoc.location.pathname : '/'; } /** Save the in-memory page as a private server-side draft. */ async function saveDraft() { try { const result = await api('saveDraft', {uri: currentUri(), content: serialisePage()}, 'POST'); dirty = false; showMessage(result.message, 'success'); } catch (error) { showMessage(error.message, 'error'); } } /** Run the pre-publish accessibility prompt and publish the complete page. */ async function publishPage() { if (!permissions.publish) return; try { const issues = accessibilityIssues(); if (issues.length && !window.confirm('The accessibility check found ' + issues.length + ' issue(s). Publish anyway?')) return; const result = await api('save', {uri: currentUri(), content: serialisePage()}, 'POST'); dirty = false; showMessage(result.message, 'success'); addBlockControls(); } catch (error) { showMessage(error.message, 'error'); } } /** Offer to replace the public preview with its most recent saved draft. */ 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?')) { iframeDoc.open(); iframeDoc.write(draft.content); iframeDoc.close(); dirty = true; } } 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'); 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 list = $('#fileList').empty(); pages.forEach(function (page) { const row = $('
  • ').attr('data-search', (page.name + ' ' + page.title).toLowerCase()); $('