Add site scan, editor-based publishing workflow and subfolder support
Adds a whole-site "Site scan" that analyses pages, images and SEO tags and makes pages editable in one step, moves Save Draft / Schedule Publish / Publish Now into the content editor, supports installs in a subfolder, and hardens metadata handling. Only the cms/ directory is changed. Added - Site scan (Tools > Site scan, administrators): reports pages, folders, editable/untagged pages, images without alt text, broken local references, unused uploads and missing title/description/Open Graph tags; then tags <main> or top-level article/section content areas, images outside editable regions and photo placeholders, and inserts only missing SEO tags. Page source is edited byte-for-byte (no DOM re-serialisation), a revision is kept for every changed page, later scans list only pages that still need changes, and work runs in small batches with a progress bar and "N of M pages" count. New administrator-only actions: listSitePages, analyseSitePages, applySiteTagging. - Click-to-edit images and photo placeholders (role="img" boxes) with a Media Library picker and alt text. - Subfolder installs (basePath / siteRoot auto-detected); root installs behave as before. - Audit/activity entries for cancelled schedules, image uploads, alt-text changes and failed scheduled publications (shared Activity class). - Snackbar (success/error) and table-style dialogs for the page picker, revision history, dashboard, shared content, menus and site scan. Changed - Editor actions are now Save Draft | Schedule Publish | Publish Now | Cancel; saving in the editor and applying image/SEO changes stores a private draft. The toolbar no longer has Save Draft, Schedule or Publish. - CMSController split into PagePaths, ContentDom and Activity; entry points share cms/bootstrap.php. Restoring a deleted page uses PageNotFoundException instead of matching an error message. Fixed - Lost updates on JSON metadata (locked FileStore::update). - Non-atomic site-wide shared-content/menu writes; non-ASCII text handling. - Scheduled publishing contending for a lock on every request. - Revision pruning dropping the newest revision (revisions gain a "ts"). - Stale cached pages in the editor preview after a site is replaced. - Saved drafts reloading without stylesheets or the correct page path, and Publish discarding a draft that was not re-offered in the same session. - HTML entities in page titles; backslashes in folder names on Windows. Migration: no manual steps. basePath/siteRoot are optional and detected automatically. The analyseSite action was replaced by listSitePages and analyseSitePages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c34564a0a9
commit
d3f196570d
+483
-73
@@ -11,6 +11,7 @@
|
||||
// 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;
|
||||
@@ -21,10 +22,27 @@
|
||||
// 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 <img> or <svg>.
|
||||
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 () {
|
||||
@@ -51,7 +69,7 @@
|
||||
if ((method || 'GET') === 'POST') {
|
||||
payload.csrf_token = csrfToken;
|
||||
}
|
||||
return $.ajax({url: '/cms/controller/', method: method || 'GET', data: payload, dataType: 'json'})
|
||||
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');
|
||||
@@ -64,6 +82,8 @@
|
||||
$('.cms-dialog, #newPageDialog, #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));
|
||||
$('#editModal').dialog({
|
||||
autoOpen: false,
|
||||
modal: true,
|
||||
@@ -78,9 +98,6 @@
|
||||
$('#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'); });
|
||||
@@ -88,6 +105,9 @@
|
||||
$('#accessibilityButton').on('click', runAccessibilityCheck);
|
||||
$('#sharedButton').on('click', openShared);
|
||||
$('#menusButton').on('click', openMenus);
|
||||
$('#siteScanButton').on('click', openSiteScan);
|
||||
$('#siteScanApply').on('click', applySiteScan);
|
||||
$('#imageForm').on('submit', applyImage);
|
||||
$('#seoForm').on('submit', applySeo);
|
||||
$('#scheduleForm').on('submit', schedulePage);
|
||||
$('#sharedForm').on('submit', saveShared);
|
||||
@@ -107,6 +127,22 @@
|
||||
*/
|
||||
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;
|
||||
if (!draftWrite) {
|
||||
draftSaved = false;
|
||||
framePath = iframe.contentWindow.location.pathname;
|
||||
}
|
||||
iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
currentElement = null;
|
||||
|
||||
@@ -116,7 +152,12 @@
|
||||
if ($(event.target).closest('.button-container').length) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openEditor($(this));
|
||||
// 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) {
|
||||
@@ -126,11 +167,29 @@
|
||||
});
|
||||
$(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 <style> keeps it out of saved pages.
|
||||
$('<style id="neo-editor-style">').text(
|
||||
'img[data-neo-image],' + placeholderSelector + '[data-neo-image]{cursor:pointer;outline:2px dashed #4a90d9;outline-offset:2px}'
|
||||
+ editableSelector + ' img,' + editableSelector + ' ' + placeholderSelector + '{cursor:pointer}'
|
||||
+ editableSelector + ' img:hover,' + editableSelector + ' ' + placeholderSelector + ':hover{outline:2px dashed #4a90d9;outline-offset:2px}'
|
||||
).appendTo(iframeDoc.head || iframeDoc.documentElement);
|
||||
$(iframeDoc).on('click.neocms', 'img[data-neo-image], ' + placeholderSelector + '[data-neo-image]', function (event) {
|
||||
if ($(this).closest(editableSelector).length) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openImageEditor($(this));
|
||||
});
|
||||
addBlockControls();
|
||||
updateUrl();
|
||||
offerDraft();
|
||||
}
|
||||
|
||||
/** The photo-placeholder box containing a click target, when it has no real image yet. */
|
||||
function imagePlaceholder(target) {
|
||||
return $(target).closest(placeholderSelector).filter(function () { return !$(this).find('img').length; });
|
||||
}
|
||||
|
||||
/** Open TinyMCE with the selected region's inner HTML. */
|
||||
function openEditor(element) {
|
||||
currentElement = element;
|
||||
@@ -167,7 +226,7 @@
|
||||
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,
|
||||
url: basePath + '/cms/image_upload.php', method: 'POST', data: form, processData: false, contentType: false,
|
||||
xhr: function () {
|
||||
const xhr = $.ajaxSettings.xhr();
|
||||
xhr.upload.addEventListener('progress', function (event) {
|
||||
@@ -180,16 +239,34 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Apply modal edits to the iframe only; the page remains unpublished until explicitly saved.
|
||||
// Save applies the edit to the preview and keeps it as a private draft; the public page changes only on Publish.
|
||||
$('#saveBtn').on('click', function () {
|
||||
const editor = tinymce.get('editor');
|
||||
if (editor && currentElement) {
|
||||
currentElement.html(editor.getContent());
|
||||
markDirty();
|
||||
}
|
||||
const changed = applyEditorContent();
|
||||
$('#editModal').dialog('close');
|
||||
if (changed) commitEdit();
|
||||
});
|
||||
|
||||
// Publish Now and Schedule Publish act on the whole page, so the editor content is applied to the preview first.
|
||||
// The editor stays open until the action is confirmed, so a cancelled confirm or dialogue loses nothing.
|
||||
$('#publishBtn').on('click', async function () {
|
||||
if (!applyEditorContent()) return;
|
||||
markDirty();
|
||||
if (await publishPage()) $('#editModal').dialog('close');
|
||||
});
|
||||
$('#scheduleBtn').on('click', function () {
|
||||
if (!applyEditorContent()) return;
|
||||
markDirty();
|
||||
openSchedule();
|
||||
});
|
||||
|
||||
/** Copy the content editor's HTML into the selected region of the preview; false when there is nothing to apply. */
|
||||
function applyEditorContent() {
|
||||
const editor = tinymce.get('editor');
|
||||
if (!(editor && currentElement)) return false;
|
||||
currentElement.html(editor.getContent());
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Clone a repeatable block before or after its source, then open the clone for editing. */
|
||||
function duplicateBlock(event) {
|
||||
event.preventDefault();
|
||||
@@ -209,7 +286,7 @@
|
||||
event.stopPropagation();
|
||||
if (window.confirm('Delete this content block?')) {
|
||||
$(this).closest('.neo-dupe').remove();
|
||||
markDirty();
|
||||
commitEdit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,37 +314,67 @@
|
||||
$(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 '<!DOCTYPE html>\n' + clone.documentElement.outerHTML;
|
||||
}
|
||||
|
||||
/** Return the public path of the page currently displayed in the iframe. */
|
||||
function currentUri() {
|
||||
return iframeDoc ? iframeDoc.location.pathname : '/';
|
||||
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. */
|
||||
async function saveDraft() {
|
||||
/** Save the in-memory page as a private server-side draft; the public page is untouched. */
|
||||
async function saveDraft(message) {
|
||||
try {
|
||||
const result = await api('saveDraft', {uri: currentUri(), content: serialisePage()}, 'POST');
|
||||
await api('saveDraft', {uri: currentUri(), content: serialisePage()}, 'POST');
|
||||
dirty = false;
|
||||
showMessage(result.message, 'success');
|
||||
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;
|
||||
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;
|
||||
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();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
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 (/<base[\s>]/i.test(html)) return html;
|
||||
const tag = '<base data-neo-base href="' + pageUrl.replace(/&/g, '&').replace(/"/g, '"') + '">';
|
||||
return /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, function (head) { return head + tag; }) : tag + html;
|
||||
}
|
||||
|
||||
async function offerDraft() {
|
||||
const uri = currentUri();
|
||||
if (loadedDrafts.has(uri) || uri.startsWith('/cms/')) return;
|
||||
@@ -275,8 +382,13 @@
|
||||
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;
|
||||
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.
|
||||
@@ -297,6 +409,7 @@
|
||||
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'); }
|
||||
}
|
||||
@@ -305,32 +418,80 @@
|
||||
async function openPages() {
|
||||
try {
|
||||
const pages = await api('getPages');
|
||||
const list = $('#fileList').empty();
|
||||
const body = $('#fileList tbody').empty();
|
||||
$('#fileList .manage-col').toggle(permissions.manage);
|
||||
$('#pageSearch').val('');
|
||||
$('#pageListEmpty').prop('hidden', true);
|
||||
pages.forEach(function (page) {
|
||||
const row = $('<li>').attr('data-search', (page.name + ' ' + page.title).toLowerCase());
|
||||
$('<button class="page-open">').text(page.title + ' - ' + page.name + (page.draft ? ' [draft]' : '')).on('click', function () { navigateTo(page.url); }).appendTo(row);
|
||||
const row = $('<tr class="page-row">').attr('data-search', (page.name + ' ' + page.title).toLowerCase()).on('click', function () { navigateTo(page.url); });
|
||||
const name = $('<td class="page-cell">').append(docIcon());
|
||||
$('<button type="button" class="page-open">').text(page.name).appendTo(name);
|
||||
if (page.draft) $('<span class="badge-draft">').text('Draft').appendTo(name);
|
||||
row.append(name, $('<td class="page-title">').text(page.title), $('<td class="page-date">').text(formatDate(page.modified)));
|
||||
const actions = $('<td class="row-actions manage-col">').toggle(permissions.manage).appendTo(row);
|
||||
if (permissions.manage) {
|
||||
$('<button>').text('Duplicate').on('click', function () { managePage('duplicate', page.url); }).appendTo(row);
|
||||
$('<button>').text('Rename').on('click', function () { managePage('rename', page.url); }).appendTo(row);
|
||||
$('<button class="danger-text">').text('Delete').on('click', function () { managePage('delete', page.url); }).appendTo(row);
|
||||
[['Duplicate', 'duplicate'], ['Rename', 'rename'], ['Delete', 'delete']].forEach(function (action) {
|
||||
$('<button type="button">').text(action[0]).toggleClass('danger-text', action[1] === 'delete')
|
||||
.on('click', function (event) { event.stopPropagation(); managePage(action[1], page.url); }).appendTo(actions);
|
||||
});
|
||||
}
|
||||
list.append(row);
|
||||
body.append(row);
|
||||
});
|
||||
$('#fileListDialog').dialog('open');
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Small line icon; paths are SVG path markup drawn on a 24px grid. */
|
||||
function svgIcon(paths) {
|
||||
return $('<svg class="doc-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + paths + '</svg>');
|
||||
}
|
||||
|
||||
/** Document icon shown beside each page. */
|
||||
function docIcon() {
|
||||
return svgIcon('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h6"/>');
|
||||
}
|
||||
|
||||
/** Clock icon shown beside revision times. */
|
||||
function clockIcon() {
|
||||
return svgIcon('<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an empty table to a container and return its body.
|
||||
* Headings may be text or ready-made elements (for example a select-all checkbox).
|
||||
*/
|
||||
function dataTable(container, headings) {
|
||||
const head = $('<tr>');
|
||||
headings.forEach(function (heading) { $('<th>').append(typeof heading === 'string' ? document.createTextNode(heading) : heading).appendTo(head); });
|
||||
const body = $('<tbody>');
|
||||
$('<div class="table-wrap">').append($('<table class="data-table">').append($('<thead>').append(head), body)).appendTo(container);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** A small button for a table row's action column; clicks do not also trigger the row. */
|
||||
function rowButton(label, handler, danger) {
|
||||
return $('<button type="button" class="row-button">').text(label).toggleClass('danger-text', !!danger)
|
||||
.on('click', function (event) { event.stopPropagation(); handler(); });
|
||||
}
|
||||
|
||||
/** Short local date and time for the page table. */
|
||||
function formatDate(iso) {
|
||||
const date = new Date(iso);
|
||||
return isNaN(date) ? '' : date.toLocaleDateString(undefined, {day: 'numeric', month: 'short', year: 'numeric'}) + ', ' + date.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'});
|
||||
}
|
||||
|
||||
/** Hide page-picker rows that do not contain the case-insensitive search text. */
|
||||
function filterPages() {
|
||||
const query = $(this).val().toLowerCase();
|
||||
$('#fileList li').each(function () { $(this).toggle($(this).data('search').includes(query)); });
|
||||
$('#fileList tbody tr').each(function () { $(this).toggle($(this).data('search').includes(query)); });
|
||||
$('#pageListEmpty').prop('hidden', $('#fileList tbody tr:visible').length > 0);
|
||||
}
|
||||
|
||||
/** Navigate the preview after protecting any unpublished changes. */
|
||||
function navigateTo(url) {
|
||||
if (dirty && !window.confirm('Discard unpublished changes?')) return;
|
||||
dirty = false;
|
||||
$('#frameContainer').attr('src', url);
|
||||
$('#frameContainer').attr('src', basePath + url);
|
||||
$('#fileListDialog').dialog('close');
|
||||
}
|
||||
|
||||
@@ -356,7 +517,7 @@
|
||||
const templates = await api('getTemplates');
|
||||
const list = $('#radioList').empty();
|
||||
templates.forEach(function (item, index) {
|
||||
$('<label>').append($('<input type="radio" name="item">').val(item.id).prop('checked', index === 0), document.createTextNode(' ' + item.name)).appendTo(list);
|
||||
$('<label class="template-option">').append($('<input type="radio" name="item">').val(item.id).prop('checked', index === 0), docIcon(), $('<span>').text(item.name)).appendTo(list);
|
||||
});
|
||||
$('#newPageDialog').dialog('open');
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
@@ -378,12 +539,18 @@
|
||||
try {
|
||||
const revisions = await api('revisions', {uri: currentUri()});
|
||||
const list = $('#revisionsList').empty();
|
||||
if (!revisions.length) list.text('No revisions have been recorded for this page.');
|
||||
revisions.forEach(function (revision) {
|
||||
const row = $('<div class="list-row">').text(new Date(revision.created).toLocaleString() + ' - ' + revision.user + ' - ' + revision.reason + ' ');
|
||||
if (permissions.publish) $('<button>').text('Restore').on('click', function () { restoreRevision(revision.id); }).appendTo(row);
|
||||
list.append(row);
|
||||
});
|
||||
if (!revisions.length) {
|
||||
$('<p class="empty-state">').text('No revisions have been recorded for this page.').appendTo(list);
|
||||
} else {
|
||||
const body = dataTable(list, permissions.publish ? ['When', 'User', 'Reason', 'Actions'] : ['When', 'User', 'Reason']);
|
||||
revisions.forEach(function (revision) {
|
||||
const row = $('<tr>');
|
||||
$('<td class="page-cell">').append(clockIcon(), document.createTextNode(formatDate(revision.created))).appendTo(row);
|
||||
row.append($('<td>').text(revision.user), $('<td class="page-title">').text(revision.reason));
|
||||
if (permissions.publish) $('<td class="row-actions">').append(rowButton('Restore', function () { restoreRevision(revision.id); })).appendTo(row);
|
||||
body.append(row);
|
||||
});
|
||||
}
|
||||
$('#toolsDialog').dialog('close');
|
||||
$('#revisionsDialog').dialog('open');
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
@@ -429,8 +596,8 @@
|
||||
return;
|
||||
}
|
||||
currentElement.append($('<img>').attr({src: item.url, alt: item.alt || ''}));
|
||||
markDirty();
|
||||
$('#mediaDialog').dialog('close');
|
||||
commitEdit();
|
||||
}
|
||||
|
||||
/** Delete a media file, warning more firmly when public pages still reference it. */
|
||||
@@ -439,6 +606,182 @@
|
||||
try { showMessage((await api('deleteMedia', {name: item.name}, 'POST')).message, 'success'); openMedia(); } catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
// Pages per request: small enough for smooth progress updates, large enough to keep request overhead low.
|
||||
const SCAN_BATCH = 10;
|
||||
const APPLY_BATCH = 5;
|
||||
let siteScanBusy = false;
|
||||
|
||||
/** Show the scan progress bar with a file count; without a total the bar is indeterminate. */
|
||||
function showScanProgress(label, done, total, current) {
|
||||
const bar = $('#scanProgressBar');
|
||||
if (total > 0) bar.attr('max', total).val(done); else bar.removeAttr('value');
|
||||
$('#scanProgressText').text(total > 0
|
||||
? label + ': ' + done + ' of ' + total + ' pages (' + Math.round(done / total * 100) + '%)' + (current ? ' - ' + current : '')
|
||||
: label + '...');
|
||||
$('#siteScanProgress').prop('hidden', false);
|
||||
}
|
||||
|
||||
/** Lock the scan controls while a batch run is in progress. */
|
||||
function setScanBusy(busy) {
|
||||
siteScanBusy = busy;
|
||||
$('#siteScanApply, #siteScanOptions input, #siteScanPages input').prop('disabled', busy);
|
||||
}
|
||||
|
||||
/** Open the scan dialogue and analyse the site. */
|
||||
async function openSiteScan() {
|
||||
$('#toolsDialog').dialog('close');
|
||||
$('#siteScanDialog').dialog('open');
|
||||
await runSiteScan();
|
||||
}
|
||||
|
||||
/** Analyse every page in small batches so progress and a page count can be shown. */
|
||||
async function runSiteScan() {
|
||||
if (siteScanBusy) return;
|
||||
setScanBusy(true);
|
||||
$('#siteScanSummary, #siteScanPages').empty();
|
||||
try {
|
||||
showScanProgress('Finding pages');
|
||||
const listing = await api('listSitePages');
|
||||
const results = [];
|
||||
const used = new Set();
|
||||
for (let i = 0; i < listing.pages.length; i += SCAN_BATCH) {
|
||||
const batch = listing.pages.slice(i, i + SCAN_BATCH);
|
||||
showScanProgress('Analysing', i, listing.pages.length, batch[0]);
|
||||
const result = await api('analyseSitePages', {uris: JSON.stringify(batch)});
|
||||
results.push.apply(results, result.pages);
|
||||
result.usedUploads.forEach(function (name) { used.add(name); });
|
||||
}
|
||||
if (listing.pages.length) showScanProgress('Analysed', listing.pages.length, listing.pages.length);
|
||||
else $('#scanProgressText').text('No pages found.');
|
||||
setScanBusy(false);
|
||||
renderSiteScan(buildSiteScan(results, listing.uploads, used));
|
||||
} catch (error) {
|
||||
showMessage(error.message, 'error');
|
||||
$('#siteScanProgress').prop('hidden', true);
|
||||
} finally {
|
||||
setScanBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Combine per-page results into the site totals, folder counts, and upload usage shown in the report. */
|
||||
function buildSiteScan(pages, uploads, usedUploads) {
|
||||
const summary = {pages: pages.length, editable: 0, needTagging: 0, manual: 0, images: 0, missingAlt: 0, brokenRefs: 0, seoGaps: 0};
|
||||
const structure = {};
|
||||
pages.forEach(function (page) {
|
||||
summary.editable += page.editableRegions > 0 ? 1 : 0;
|
||||
summary.needTagging += !page.editableRegions && page.proposed.length ? 1 : 0;
|
||||
summary.manual += !page.editableRegions && !page.proposed.length ? 1 : 0;
|
||||
summary.images += page.images;
|
||||
summary.missingAlt += page.missingAlt;
|
||||
summary.brokenRefs += page.broken.length;
|
||||
summary.seoGaps += page.seoMissing.some(function (key) { return key === 'title' || key === 'description'; }) ? 1 : 0;
|
||||
const folder = page.uri.replace(/[^/]*$/, '').replace(/(.)\/$/, '$1');
|
||||
structure[folder] = (structure[folder] || 0) + 1;
|
||||
});
|
||||
return {
|
||||
summary: summary, structure: structure, pages: pages,
|
||||
uploads: {files: uploads.length, unused: uploads.filter(function (name) { return !usedUploads.has(name); }).length}
|
||||
};
|
||||
}
|
||||
|
||||
/** Render scan totals, folder structure, and one selectable row per page. */
|
||||
function renderSiteScan(scan) {
|
||||
const s = scan.summary;
|
||||
const summary = $('#siteScanSummary').empty();
|
||||
const stats = $('<div class="stat-row">').appendTo(summary);
|
||||
[
|
||||
[s.pages, 'pages found', ''], [s.editable, 'already editable', ''], [s.needTagging, 'can be made editable', ''],
|
||||
[s.manual, 'need manual tagging', 'No safe content area was found on these pages.'],
|
||||
[s.images + ' (' + s.missingAlt + ' without alt)', 'images', ''],
|
||||
[s.brokenRefs, 'broken references', 'Local image, CSS, or script files that do not exist.'],
|
||||
[s.seoGaps, 'missing title or description', ''], [scan.uploads.files + ' (' + scan.uploads.unused + ' unused)', 'uploaded files', '']
|
||||
].forEach(function (stat) { $('<div class="stat">').attr('title', stat[2]).append($('<strong>').text(stat[0]), document.createTextNode(' ' + stat[1])).appendTo(stats); });
|
||||
$('<p class="scan-note">').text('Folders: ' + Object.keys(scan.structure).map(function (dir) { return dir + ' (' + scan.structure[dir] + ')'; }).join(', ')).appendTo(summary);
|
||||
|
||||
// After the first run only pages with something new to do are listed; finished pages stay out of the way.
|
||||
const pending = scan.pages.filter(function (page) { return page.changes.length > 0; });
|
||||
const list = $('#siteScanPages').empty();
|
||||
if (!pending.length) {
|
||||
$('<p class="success-text">').text('Nothing new to change: all ' + scan.pages.length + ' page(s) are up to date.').appendTo(list);
|
||||
} else {
|
||||
$('<p>').text(pending.length + ' page(s) have changes to make (' + (scan.pages.length - pending.length) + ' already up to date):').appendTo(list);
|
||||
const all = $('<input type="checkbox" checked aria-label="Select all pages">');
|
||||
const body = dataTable(list, [all, 'Page', 'Changes to make']);
|
||||
all.on('change', function () { body.find('input:not(:disabled)').prop('checked', this.checked); });
|
||||
pending.forEach(function (page) {
|
||||
const row = $('<tr class="page-row">');
|
||||
const box = $('<input type="checkbox" checked>').val(page.uri).on('click', function (event) { event.stopPropagation(); });
|
||||
row.on('click', function () { if (!box.prop('disabled')) box.prop('checked', !box.prop('checked')); });
|
||||
$('<td class="check-cell">').append(box).appendTo(row);
|
||||
const name = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(page.uri)).appendTo(row);
|
||||
if (page.title) $('<span class="page-sub">').text(page.title).appendTo(name);
|
||||
$('<td class="page-title">').text(page.changes.join('; ')).appendTo(row);
|
||||
body.append(row);
|
||||
});
|
||||
}
|
||||
$('#siteScanApply').prop('disabled', !pending.length);
|
||||
}
|
||||
|
||||
/** Add the chosen markers to the ticked pages in batches, showing progress, then refresh the report. */
|
||||
async function applySiteScan() {
|
||||
if (siteScanBusy) return;
|
||||
const uris = $('#siteScanPages tbody input:checked').map(function () { return $(this).val(); }).get();
|
||||
if (!uris.length) { showMessage('Select at least one page.', 'error'); return; }
|
||||
if (!window.confirm('Add editing markers to ' + uris.length + ' page(s)? A revision of each is kept.')) return;
|
||||
const options = {content: $('#scanContent').is(':checked'), images: $('#scanImages').is(':checked'), seo: $('#scanSeo').is(':checked')};
|
||||
setScanBusy(true);
|
||||
let updated = 0;
|
||||
try {
|
||||
for (let i = 0; i < uris.length; i += APPLY_BATCH) {
|
||||
const batch = uris.slice(i, i + APPLY_BATCH);
|
||||
showScanProgress('Updating', i, uris.length, batch[0]);
|
||||
updated += (await api('applySiteTagging', {uris: JSON.stringify(batch), options: JSON.stringify(options)}, 'POST')).updated_pages;
|
||||
}
|
||||
showScanProgress('Updated', uris.length, uris.length);
|
||||
showMessage('Updated ' + updated + ' page(s). Each has a revision to restore.', 'success');
|
||||
} catch (error) {
|
||||
showMessage(error.message + ' (' + updated + ' page(s) were updated before this error.)', 'error');
|
||||
}
|
||||
setScanBusy(false);
|
||||
// Reload the preview so it picks up the new markers, unless that would discard unpublished edits.
|
||||
if (!dirty) document.getElementById('frameContainer').contentWindow.location.reload();
|
||||
await runSiteScan();
|
||||
}
|
||||
/** Open the image dialogue for an image outside any editable region, offering the media library. */
|
||||
async function openImageEditor(image) {
|
||||
currentImage = image;
|
||||
// A placeholder has no image yet, so it starts empty.
|
||||
$('#imageSrc').val(image.is('img') ? image.attr('src') || '' : '');
|
||||
$('#imageAlt').val(image.is('img') ? image.attr('alt') || '' : '');
|
||||
const picker = $('#imagePicker').empty();
|
||||
try {
|
||||
(await api('media')).forEach(function (item) {
|
||||
$('<img>').attr({src: item.url, alt: item.alt || '', title: item.name}).on('click', function () {
|
||||
$('#imageSrc').val(item.url);
|
||||
if (!$('#imageAlt').val()) $('#imageAlt').val(item.alt || '');
|
||||
}).appendTo(picker);
|
||||
});
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
$('#imageDialog').dialog('open');
|
||||
}
|
||||
|
||||
/** Apply the dialogue's image address and alternative text to the preview. */
|
||||
function applyImage(event) {
|
||||
event.preventDefault();
|
||||
if (!currentImage) return;
|
||||
if (currentImage.is('img')) {
|
||||
currentImage.attr({src: $('#imageSrc').val(), alt: $('#imageAlt').val()});
|
||||
} else {
|
||||
// Fill the placeholder's own shape (size, ratio, rounded corners) with the chosen image.
|
||||
const photo = $('<img data-neo-image>').attr({src: $('#imageSrc').val(), alt: $('#imageAlt').val()})
|
||||
.css({display: 'block', width: '100%', height: '100%', 'object-fit': 'cover', 'border-radius': 'inherit'});
|
||||
currentImage.removeAttr('role aria-label data-neo-image').empty().append(photo);
|
||||
}
|
||||
currentImage = null;
|
||||
$('#imageDialog').dialog('close');
|
||||
commitEdit();
|
||||
}
|
||||
|
||||
/** Read current document metadata into the SEO form. */
|
||||
function openSeo() {
|
||||
if (!iframeDoc) return;
|
||||
@@ -462,9 +805,8 @@
|
||||
let canonical = $(iframeDoc).find('link[rel="canonical"]');
|
||||
if (!canonical.length) canonical = $('<link rel="canonical">').appendTo(iframeDoc.head);
|
||||
canonical.attr('href', $('#seoCanonical').val());
|
||||
markDirty();
|
||||
$('#seoDialog').dialog('close');
|
||||
showMessage('SEO settings applied. Publish the page to make them live.', 'success');
|
||||
commitEdit('SEO settings saved to the draft.' + (permissions.publish ? ' Publish to make them live.' : ''));
|
||||
}
|
||||
|
||||
/** Return a named metadata value, or an empty string when the tag is absent. */
|
||||
@@ -520,12 +862,21 @@
|
||||
async function openShared() {
|
||||
try {
|
||||
const blocks = await api('shared');
|
||||
const keys = Object.keys(blocks);
|
||||
const list = $('#sharedList').empty();
|
||||
Object.keys(blocks).forEach(function (key) {
|
||||
$('<button class="list-row">').text(key + ' - updated ' + new Date(blocks[key].updated).toLocaleString()).on('click', function () {
|
||||
$('#sharedKey').val(key); $('#sharedContent').val(blocks[key].content);
|
||||
}).appendTo(list);
|
||||
});
|
||||
if (!keys.length) {
|
||||
$('<p class="empty-state">').text('No shared blocks yet. Use the form below to create one.').appendTo(list);
|
||||
} else {
|
||||
const body = dataTable(list, ['Block', 'Last updated', 'Updated by', 'Actions']);
|
||||
keys.forEach(function (key) {
|
||||
const load = function () { $('#sharedKey').val(key); $('#sharedContent').val(blocks[key].content); };
|
||||
const row = $('<tr class="page-row">').on('click', load);
|
||||
$('<td class="page-cell">').append(docIcon(), $('<strong>').text(key)).appendTo(row);
|
||||
row.append($('<td class="page-date">').text(formatDate(blocks[key].updated)), $('<td>').text(blocks[key].user || ''),
|
||||
$('<td class="row-actions">').append(rowButton('Edit', load)));
|
||||
body.append(row);
|
||||
});
|
||||
}
|
||||
$('#toolsDialog').dialog('close');
|
||||
$('#sharedDialog').dialog('open');
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
@@ -539,9 +890,10 @@
|
||||
const result = await api('saveShared', {key: key, content: $('#sharedContent').val()}, 'POST');
|
||||
if (currentElement && window.confirm('Mark the currently selected editable region as this shared block?')) {
|
||||
currentElement.attr('data-neo-shared', key).html($('#sharedContent').val());
|
||||
markDirty();
|
||||
await commitEdit(result.message + ' This page was also saved as a draft.');
|
||||
} else {
|
||||
showMessage(result.message, 'success');
|
||||
}
|
||||
showMessage(result.message, 'success');
|
||||
openShared();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
@@ -550,13 +902,24 @@
|
||||
async function openMenus() {
|
||||
try {
|
||||
const menus = await api('menus');
|
||||
const names = Object.keys(menus);
|
||||
const list = $('#menuList').empty();
|
||||
Object.keys(menus).forEach(function (name) {
|
||||
$('<button class="list-row">').text(name).on('click', function () {
|
||||
$('#menuName').val(name);
|
||||
$('#menuItems').val(menus[name].items.map(function (item) { return item.label + ' | ' + item.url + (item.parent ? ' | ' + item.parent : ''); }).join('\n'));
|
||||
}).appendTo(list);
|
||||
});
|
||||
if (!names.length) {
|
||||
$('<p class="empty-state">').text('No menus yet. Use the form below to create one.').appendTo(list);
|
||||
} else {
|
||||
const body = dataTable(list, ['Menu', 'Items', 'Last updated', 'Actions']);
|
||||
names.forEach(function (name) {
|
||||
const load = function () {
|
||||
$('#menuName').val(name);
|
||||
$('#menuItems').val(menus[name].items.map(function (item) { return item.label + ' | ' + item.url + (item.parent ? ' | ' + item.parent : ''); }).join('\n'));
|
||||
};
|
||||
const row = $('<tr class="page-row">').on('click', load);
|
||||
$('<td class="page-cell">').append(docIcon(), $('<strong>').text(name)).appendTo(row);
|
||||
row.append($('<td>').text(menus[name].items.length), $('<td class="page-date">').text(formatDate(menus[name].updated)),
|
||||
$('<td class="row-actions">').append(rowButton('Edit', load)));
|
||||
body.append(row);
|
||||
});
|
||||
}
|
||||
$('#toolsDialog').dialog('close');
|
||||
$('#menusDialog').dialog('open');
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
@@ -572,9 +935,11 @@
|
||||
try {
|
||||
const result = await api('saveMenu', {name: $('#menuName').val(), items: JSON.stringify(items)}, 'POST');
|
||||
if (currentElement && window.confirm('Insert this menu into the currently selected editable region?')) {
|
||||
currentElement.html(result.html); markDirty();
|
||||
currentElement.html(result.html);
|
||||
await commitEdit(result.message + ' This page was also saved as a draft.');
|
||||
} else {
|
||||
showMessage(result.message, 'success');
|
||||
}
|
||||
showMessage(result.message, 'success');
|
||||
openMenus();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
@@ -589,20 +954,51 @@
|
||||
permissions = dashboard.permissions;
|
||||
$('.publish-only').toggle(permissions.publish);
|
||||
$('.manage-only').toggle(permissions.manage);
|
||||
$('#saveDraft').toggle(permissions.draft);
|
||||
const content = $('#dashboardContent').empty();
|
||||
$('<h3>').text('Pending work').appendTo(content);
|
||||
$('<p>').text(Object.keys(dashboard.drafts).length + ' draft(s), ' + Object.keys(dashboard.schedules).length + ' scheduled publication(s).').appendTo(content);
|
||||
Object.keys(dashboard.schedules).forEach(function (id) {
|
||||
const job = dashboard.schedules[id];
|
||||
const row = $('<div class="list-row">').text(job.uri + ' - ' + new Date(job.publish_at).toLocaleString() + ' ');
|
||||
if (permissions.schedule) $('<button>').text('Cancel').on('click', function () { cancelSchedule(id); }).appendTo(row);
|
||||
content.append(row);
|
||||
});
|
||||
$('<h3>').text('Recent activity').appendTo(content);
|
||||
dashboard.activity.forEach(function (entry) { $('<div class="list-row">').text(new Date(entry.created).toLocaleString() + ' - ' + entry.user + ' - ' + entry.action + ': ' + entry.target).appendTo(content); });
|
||||
const draftUris = Object.keys(dashboard.drafts);
|
||||
const jobIds = Object.keys(dashboard.schedules);
|
||||
|
||||
$('<div class="stat-row">').append(
|
||||
$('<div class="stat">').append($('<strong>').text(draftUris.length), document.createTextNode(' draft(s)')),
|
||||
$('<div class="stat">').append($('<strong>').text(jobIds.length), document.createTextNode(' scheduled publication(s)'))
|
||||
).appendTo(content);
|
||||
|
||||
if (jobIds.length) {
|
||||
$('<h3 class="dialog-section">').text('Scheduled publications').appendTo(content);
|
||||
const body = dataTable(content, permissions.schedule ? ['Page', 'Publishes', 'Status', 'Actions'] : ['Page', 'Publishes', 'Status']);
|
||||
jobIds.forEach(function (id) {
|
||||
const job = dashboard.schedules[id];
|
||||
const row = $('<tr>');
|
||||
$('<td class="page-cell">').append(docIcon(), document.createTextNode(job.uri)).appendTo(row);
|
||||
row.append($('<td class="page-date">').text(formatDate(job.publish_at)),
|
||||
$('<td>').append($('<span>').addClass(job.status === 'failed' ? 'badge-failed' : 'badge-pending').text(job.status === 'failed' ? 'Failed' : 'Pending')));
|
||||
if (permissions.schedule) $('<td class="row-actions">').append(rowButton('Cancel', function () { cancelSchedule(id); })).appendTo(row);
|
||||
body.append(row);
|
||||
});
|
||||
}
|
||||
if (draftUris.length) {
|
||||
$('<h3 class="dialog-section">').text('Drafts').appendTo(content);
|
||||
const body = dataTable(content, ['Page', 'Saved', 'By']);
|
||||
draftUris.forEach(function (uri) {
|
||||
const row = $('<tr>');
|
||||
$('<td class="page-cell">').append(docIcon(), document.createTextNode(uri)).appendTo(row);
|
||||
row.append($('<td class="page-date">').text(formatDate(dashboard.drafts[uri].updated)), $('<td>').text(dashboard.drafts[uri].user || ''));
|
||||
body.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
$('<h3 class="dialog-section">').text('Recent activity').appendTo(content);
|
||||
if (!dashboard.activity.length) {
|
||||
$('<p class="empty-state">').text('No activity has been recorded yet.').appendTo(content);
|
||||
} else {
|
||||
const body = dataTable(content, ['When', 'User', 'Action', 'Target']);
|
||||
dashboard.activity.forEach(function (entry) {
|
||||
body.append($('<tr>').append($('<td class="page-date">').text(formatDate(entry.created)), $('<td>').text(entry.user),
|
||||
$('<td>').text(entry.action), $('<td class="page-title">').text(entry.target)));
|
||||
});
|
||||
}
|
||||
if (dashboard.problems.length) {
|
||||
$('<h3>').text('System problems').appendTo(content);
|
||||
$('<h3 class="dialog-section">').text('System problems').appendTo(content);
|
||||
dashboard.problems.forEach(function (problem) { $('<div class="issue">').text(problem).appendTo(content); });
|
||||
}
|
||||
if (open) $('#dashboardDialog').dialog('open');
|
||||
@@ -617,7 +1013,7 @@
|
||||
/** End the session after protecting unpublished browser changes. */
|
||||
async function logout() {
|
||||
if (dirty && !window.confirm('Log out and discard unpublished changes?')) return;
|
||||
try { await api('logout', {}, 'POST'); window.location.href = '/cms/login/'; } catch (error) { showMessage(error.message, 'error'); }
|
||||
try { await api('logout', {}, 'POST'); window.location.href = basePath + '/cms/login/'; } catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Mark the preview as unpublished and refresh its toolbar status. */
|
||||
@@ -628,7 +1024,7 @@
|
||||
|
||||
/** Display the active path and whether browser changes remain unpublished. */
|
||||
function updateUrl() {
|
||||
$('#urlbox').empty().append($('<strong>').text(dirty ? 'Unpublished: ' : 'Editing: '), $('<span>').text(iframeDoc ? iframeDoc.location.pathname : ''));
|
||||
$('#urlbox').empty().append($('<strong>').text(dirty ? 'Unsaved: ' : draftSaved ? 'Draft: ' : 'Editing: '), $('<span>').text(iframeDoc ? framePath : ''));
|
||||
}
|
||||
/** Format a byte count compactly for media cards. */
|
||||
function formatBytes(bytes) {
|
||||
@@ -639,8 +1035,22 @@
|
||||
})(jQuery);
|
||||
|
||||
/** Display a temporary, colour-coded status message above the administration interface. */
|
||||
/** Show a success or error snackbar; errors stay longer and are announced to assistive technology immediately. */
|
||||
function showMessage(message, type) {
|
||||
const bar = jQuery('#message-bar');
|
||||
bar.stop(true, true).text(message || 'Done').css({backgroundColor: type === 'error' ? '#a51d2d' : '#18733c'}).slideDown();
|
||||
window.setTimeout(function () { bar.slideUp(); }, 5000);
|
||||
const isError = type === 'error';
|
||||
bar.attr('role', isError ? 'alert' : 'status').removeClass('show is-success is-error').addClass(isError ? 'is-error' : 'is-success').prop('hidden', false);
|
||||
bar.find('.snack-text').text(message || 'Done');
|
||||
void bar[0].offsetWidth; // Restart the transition when a message replaces another.
|
||||
bar.addClass('show');
|
||||
window.clearTimeout(showMessage.timer);
|
||||
showMessage.timer = window.setTimeout(hideMessage, isError ? 9000 : 5000);
|
||||
}
|
||||
|
||||
/** Slide the snackbar away, then remove it from the layout. */
|
||||
function hideMessage() {
|
||||
const bar = jQuery('#message-bar').removeClass('show');
|
||||
window.setTimeout(function () { if (!bar.hasClass('show')) bar.prop('hidden', true); }, 250);
|
||||
}
|
||||
|
||||
jQuery(function () { jQuery('#message-bar .snack-close').on('click', hideMessage); });
|
||||
Reference in New Issue
Block a user