Add standalone /publiser admin application
Implement a minimal, dependency-free publishing tool to manage news and articles. The app includes: - A Markdown-based block editor with rich-text capabilities. - Image upload and resizing using GD. - Support for drafts, scheduled publishing, and a trash system. - Integrated Basic Auth protection via PHP and .htpasswd. - A global plugin for automatic execution of scheduled posts. The tool reuses core site logic in custom/plugins/publiser-lib.php to ensure consistency between the editor and the live site.
This commit is contained in:
parent
c7ec163a36
commit
b609c8596c
15 changed files with 2301 additions and 8 deletions
382
content/publiser/publiser.js
Normal file
382
content/publiser/publiser.js
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
/* /publiser block editor: rich-text toolbar for .md blocks, plain code
|
||||
* editing for .html/.php blocks, image upload (cover + inline), and a
|
||||
* small Markdown serializer for the bounded tag set the toolbar produces.
|
||||
* No external dependencies - contenteditable + document.execCommand for
|
||||
* the handful of inline/list commands (still functional everywhere,
|
||||
* despite being spec-deprecated), manual DOM ops for block-level toggles.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
document.execCommand('defaultParagraphSeparator', false, 'p');
|
||||
|
||||
// --- Markdown serialization (HTML -> MD, mirrors index.php's MD -> HTML) --
|
||||
|
||||
function serializeChildren(node) {
|
||||
return Array.from(node.childNodes).map(serializeNode).join('');
|
||||
}
|
||||
|
||||
function serializeNode(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
// Not escaping markdown-significant characters (*, [, ] ...) in plain
|
||||
// text: round-tripping literal asterisks/brackets through the parser
|
||||
// is a rare, low-severity edge case not worth the added complexity.
|
||||
return node.textContent;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
|
||||
if (node.hasAttribute('data-raw-html')) {
|
||||
// Non-editable raw-HTML island (see index.php's publiserBlockMdToHtml)
|
||||
// - pass the original source through unchanged rather than trying to
|
||||
// reconstruct it from the rendered DOM.
|
||||
return `\n\n${node.getAttribute('data-raw-html')}\n\n`;
|
||||
}
|
||||
|
||||
const tag = node.tagName.toLowerCase();
|
||||
switch (tag) {
|
||||
case 'strong':
|
||||
case 'b': {
|
||||
const inner = serializeChildren(node).trim();
|
||||
return inner ? `**${inner}**` : '';
|
||||
}
|
||||
case 'em':
|
||||
case 'i': {
|
||||
const inner = serializeChildren(node).trim();
|
||||
return inner ? `*${inner}*` : '';
|
||||
}
|
||||
case 'a': {
|
||||
const href = node.getAttribute('href') || '';
|
||||
const inner = serializeChildren(node).trim();
|
||||
return href ? `[${inner}](${href})` : inner;
|
||||
}
|
||||
case 'img': {
|
||||
const file = node.getAttribute('data-filename') || node.getAttribute('src') || '';
|
||||
const alt = node.getAttribute('alt') || '';
|
||||
return ``;
|
||||
}
|
||||
case 'br':
|
||||
return ' ';
|
||||
case 'h2':
|
||||
return `\n\n## ${serializeChildren(node).trim()}\n\n`;
|
||||
case 'h3':
|
||||
return `\n\n### ${serializeChildren(node).trim()}\n\n`;
|
||||
case 'blockquote': {
|
||||
const text = serializeChildren(node).trim().replace(/\s+/g, ' ');
|
||||
return text ? `\n\n> ${text}\n\n` : '';
|
||||
}
|
||||
case 'ul': {
|
||||
const items = Array.from(node.children)
|
||||
.filter((c) => c.tagName === 'LI')
|
||||
.map((li) => `- ${serializeChildren(li).trim()}`)
|
||||
.join('\n');
|
||||
return items ? `\n\n${items}\n\n` : '';
|
||||
}
|
||||
case 'ol': {
|
||||
const items = Array.from(node.children)
|
||||
.filter((c) => c.tagName === 'LI')
|
||||
.map((li, i) => `${i + 1}. ${serializeChildren(li).trim()}`)
|
||||
.join('\n');
|
||||
return items ? `\n\n${items}\n\n` : '';
|
||||
}
|
||||
case 'li':
|
||||
return serializeChildren(node);
|
||||
case 'p':
|
||||
case 'div': {
|
||||
const inner = serializeChildren(node).trim();
|
||||
return inner ? `\n\n${inner}\n\n` : '';
|
||||
}
|
||||
default:
|
||||
return serializeChildren(node);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeRichText(root) {
|
||||
let out = Array.from(root.childNodes).map(serializeNode).join('');
|
||||
out = out.replace(/[ \t]+\n/g, '\n');
|
||||
out = out.replace(/\n{3,}/g, '\n\n');
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
// --- dirty tracking ---------------------------------------------------
|
||||
|
||||
function markDirty(richDiv) {
|
||||
const block = richDiv.closest('.p-block');
|
||||
if (!block) return;
|
||||
const dirtyFlag = block.querySelector('.p-dirty-flag');
|
||||
const hidden = block.querySelector('.p-block-hidden');
|
||||
if (dirtyFlag) dirtyFlag.value = '1';
|
||||
if (hidden) hidden.value = serializeRichText(richDiv);
|
||||
}
|
||||
|
||||
// --- selection helpers --------------------------------------------------
|
||||
|
||||
function saveSelection(richDiv) {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return null;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!richDiv.contains(range.startContainer)) return null;
|
||||
return range.cloneRange();
|
||||
}
|
||||
|
||||
function restoreSelection(range) {
|
||||
if (!range) return;
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(el) {
|
||||
el.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
function getCurrentBlock(richDiv) {
|
||||
const sel = window.getSelection();
|
||||
if (!sel.rangeCount) return null;
|
||||
let node = sel.getRangeAt(0).startContainer;
|
||||
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
||||
while (node && node !== richDiv && !['P', 'DIV', 'H2', 'H3', 'BLOCKQUOTE', 'LI'].includes(node.tagName)) {
|
||||
node = node.parentElement;
|
||||
}
|
||||
return node === richDiv ? null : node;
|
||||
}
|
||||
|
||||
function toggleBlockTag(richDiv, tagName) {
|
||||
const block = getCurrentBlock(richDiv);
|
||||
if (!block) return;
|
||||
const targetTag = block.tagName === tagName ? 'P' : tagName;
|
||||
const replacement = document.createElement(targetTag);
|
||||
replacement.innerHTML = block.innerHTML || '<br>';
|
||||
block.replaceWith(replacement);
|
||||
placeCaretAtEnd(replacement);
|
||||
}
|
||||
|
||||
// --- image upload -------------------------------------------------------
|
||||
|
||||
async function uploadImage(file, form, target) {
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
fd.append('csrf', form.dataset.csrf);
|
||||
fd.append('location', form.dataset.location);
|
||||
fd.append('section', form.dataset.section);
|
||||
fd.append('folder', form.dataset.folder);
|
||||
fd.append('target', target);
|
||||
try {
|
||||
const res = await fetch(form.dataset.uploadUrl, { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
alert('Opplasting feilet: ' + (data.error || 'ukjent feil'));
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
alert('Opplasting feilet: ' + err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function insertImageAtRange(richDiv, range, imgData) {
|
||||
const el = document.createElement('img');
|
||||
el.src = imgData.url;
|
||||
el.alt = '';
|
||||
el.setAttribute('data-filename', imgData.filename);
|
||||
|
||||
restoreSelection(range);
|
||||
const sel = window.getSelection();
|
||||
if (sel.rangeCount && richDiv.contains(sel.getRangeAt(0).startContainer)) {
|
||||
const r = sel.getRangeAt(0);
|
||||
r.deleteContents();
|
||||
r.insertNode(el);
|
||||
r.setStartAfter(el);
|
||||
r.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(r);
|
||||
} else {
|
||||
richDiv.appendChild(el);
|
||||
}
|
||||
markDirty(richDiv);
|
||||
}
|
||||
|
||||
async function uploadAndInsertImage(richDiv, file, range) {
|
||||
const form = richDiv.closest('form');
|
||||
const data = await uploadImage(file, form, 'inline');
|
||||
if (!data) return;
|
||||
insertImageAtRange(richDiv, range, data);
|
||||
}
|
||||
|
||||
function triggerImageInsert(richDiv) {
|
||||
const savedRange = saveSelection(richDiv);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.addEventListener('change', () => {
|
||||
if (input.files[0]) uploadAndInsertImage(richDiv, input.files[0], savedRange);
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
// --- toolbar commands -----------------------------------------------
|
||||
|
||||
function execToolbarCommand(cmd, richDiv) {
|
||||
richDiv.focus();
|
||||
switch (cmd) {
|
||||
case 'bold':
|
||||
document.execCommand('bold');
|
||||
break;
|
||||
case 'italic':
|
||||
document.execCommand('italic');
|
||||
break;
|
||||
case 'ul':
|
||||
document.execCommand('insertUnorderedList');
|
||||
break;
|
||||
case 'ol':
|
||||
document.execCommand('insertOrderedList');
|
||||
break;
|
||||
case 'link': {
|
||||
const url = prompt('Lenke (URL):', 'https://');
|
||||
if (url) document.execCommand('createLink', false, url);
|
||||
break;
|
||||
}
|
||||
case 'h2':
|
||||
toggleBlockTag(richDiv, 'H2');
|
||||
break;
|
||||
case 'h3':
|
||||
toggleBlockTag(richDiv, 'H3');
|
||||
break;
|
||||
case 'quote':
|
||||
toggleBlockTag(richDiv, 'BLOCKQUOTE');
|
||||
break;
|
||||
case 'image':
|
||||
triggerImageInsert(richDiv);
|
||||
return; // markDirty happens after upload completes
|
||||
default:
|
||||
return;
|
||||
}
|
||||
markDirty(richDiv);
|
||||
}
|
||||
|
||||
// --- wire up rich text blocks -------------------------------------------
|
||||
|
||||
document.querySelectorAll('.p-block').forEach((block) => {
|
||||
const richDiv = block.querySelector('.p-richtext');
|
||||
if (!richDiv) return;
|
||||
|
||||
richDiv.addEventListener('input', () => markDirty(richDiv));
|
||||
|
||||
richDiv.addEventListener('paste', (e) => {
|
||||
const cd = e.clipboardData || window.clipboardData;
|
||||
const imageFile = Array.from(cd.files || []).find((f) => f.type.startsWith('image/'));
|
||||
e.preventDefault();
|
||||
if (imageFile) {
|
||||
const sel = window.getSelection();
|
||||
const range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
|
||||
uploadAndInsertImage(richDiv, imageFile, range);
|
||||
return;
|
||||
}
|
||||
const text = cd.getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
markDirty(richDiv);
|
||||
});
|
||||
|
||||
richDiv.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
richDiv.classList.add('dragover');
|
||||
});
|
||||
richDiv.addEventListener('dragleave', () => richDiv.classList.remove('dragover'));
|
||||
richDiv.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
richDiv.classList.remove('dragover');
|
||||
const file = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
|
||||
let range = null;
|
||||
if (document.caretRangeFromPoint) {
|
||||
range = document.caretRangeFromPoint(e.clientX, e.clientY);
|
||||
} else if (document.caretPositionFromPoint) {
|
||||
const pos = document.caretPositionFromPoint(e.clientX, e.clientY);
|
||||
if (pos) {
|
||||
range = document.createRange();
|
||||
range.setStart(pos.offsetNode, pos.offset);
|
||||
}
|
||||
}
|
||||
uploadAndInsertImage(richDiv, file, range);
|
||||
});
|
||||
|
||||
block.querySelectorAll('.p-block-toolbar [data-cmd]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => execToolbarCommand(btn.dataset.cmd, richDiv));
|
||||
});
|
||||
});
|
||||
|
||||
// --- cover image dropzone ------------------------------------------------
|
||||
|
||||
function wireCoverDrop(drop) {
|
||||
const form = drop.closest('form');
|
||||
let input = drop.querySelector('input[type=file]');
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
const previousContent = drop.innerHTML;
|
||||
drop.textContent = 'Laster opp...';
|
||||
const data = await uploadImage(file, form, 'cover');
|
||||
if (!data) {
|
||||
drop.innerHTML = previousContent;
|
||||
return;
|
||||
}
|
||||
drop.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.src = data.url;
|
||||
drop.appendChild(img);
|
||||
const newInput = document.createElement('input');
|
||||
newInput.type = 'file';
|
||||
newInput.accept = 'image/*';
|
||||
newInput.hidden = true;
|
||||
drop.appendChild(newInput);
|
||||
input = newInput;
|
||||
newInput.addEventListener('change', () => handleFile(newInput.files[0]));
|
||||
}
|
||||
|
||||
drop.addEventListener('click', () => input && input.click());
|
||||
if (input) input.addEventListener('change', () => handleFile(input.files[0]));
|
||||
|
||||
drop.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
drop.classList.add('dragover');
|
||||
});
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
|
||||
drop.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
drop.classList.remove('dragover');
|
||||
handleFile(e.dataTransfer.files && e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.p-cover-drop').forEach(wireCoverDrop);
|
||||
|
||||
// --- confirmations for destructive submit buttons -----------------------
|
||||
|
||||
document.querySelectorAll('[data-confirm]').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
if (!confirm(btn.dataset.confirm)) e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
// --- safety net: resync any dirty block right before submit -------------
|
||||
|
||||
const form = document.getElementById('editor-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', () => {
|
||||
form.querySelectorAll('.p-block[data-type="richtext"]').forEach((block) => {
|
||||
const richDiv = block.querySelector('.p-richtext');
|
||||
const dirtyFlag = block.querySelector('.p-dirty-flag');
|
||||
if (richDiv && dirtyFlag && dirtyFlag.value === '1') {
|
||||
block.querySelector('.p-block-hidden').value = serializeRichText(richDiv);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue