- Rebuild the widget server-backed: it renders only when GET state allows (logged-in FE user of the configured group), so the script tag is safe on public pages; form with mini rich text (bold/italic/ list), file attachment chips and Ctrl+V image paste - a paste lazily creates the draft feedback, uploads and inserts the image inline - Note pad badge mirrors the server item count; the pad button links to the overview page via new extension setting overviewPid, exposed as overviewUrl in GET state (slug lookup) - Embed /fileadmin images of notes into the feedback mail as CID parts: relative srcs are dead in mail clients and dev sits behind basic auth; attachment links stay absolute URLs - public/widget-test.html: dev test page, doubles as the reference implementation of the React login flow (fetch felogin JSON form incl. __RequestToken, urlencoded POST to /_json + form.action) - Docs section 5x (fe_comment v2 incl. CSRF login lesson, templateRootPaths.10 collision, cache:flush before extension:setup)
355 lines
14 KiB
JavaScript
355 lines
14 KiB
JavaScript
/**
|
||
* EXT:fe_comment v2 — page feedback widget (framework-agnostic, vanilla JS).
|
||
*
|
||
* Server-backed: everything lives in the fe_comment API (/api/fe-comment/),
|
||
* bound to the logged-in FE user. The widget only renders for users the API
|
||
* accepts (GET state -> allowed) — for everyone else it stays completely
|
||
* invisible, so the script tag is safe on public pages.
|
||
*
|
||
* Form: current page URL + a small rich-text area (bold/italic/list) with
|
||
* file attachments — including Ctrl+V image paste straight into the text.
|
||
* Saving adds the note to the user's open summary ("note pad"); the badge
|
||
* mirrors the server count and the pad button links to the overview page.
|
||
*
|
||
* Integration: <script src="/api/fe-comment/widget.js" defer></script>
|
||
* (classic TYPO3 sites get this injected by the extension automatically
|
||
* when the "JSON output" setting is off).
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
if (window.__evofcLoaded) { return; }
|
||
window.__evofcLoaded = true;
|
||
|
||
var API = '/api/fe-comment/';
|
||
var CSS_URL = '/api/fe-comment/widget.css';
|
||
var MAX_UPLOAD = 20 * 1024 * 1024;
|
||
|
||
// ---- i18n (English is the default, German when the page says so) -------
|
||
var LANG = (document.documentElement.lang || 'en').toLowerCase().indexOf('de') === 0 ? 'de' : 'en';
|
||
var TEXTS = {
|
||
en: {
|
||
open: 'Comment on this page',
|
||
formTitle: 'Feedback on this page',
|
||
urlLabel: 'Page',
|
||
noteLabel: 'Note',
|
||
placeholder: 'What did you notice? Paste screenshots right here (Ctrl+V).',
|
||
bold: 'Bold', italic: 'Italic', list: 'List',
|
||
attach: 'Attach file',
|
||
save: 'Add to note pad',
|
||
saving: 'Saving …',
|
||
savedHint: 'Saved ✓',
|
||
pad: 'Note pad',
|
||
uploadTooBig: 'File is larger than 20 MB.',
|
||
uploadFail: 'Upload failed: ',
|
||
saveFail: 'Saving failed: ',
|
||
removeAttachment: 'Remove attachment'
|
||
},
|
||
de: {
|
||
open: 'Seite kommentieren',
|
||
formTitle: 'Anmerkung zu dieser Seite',
|
||
urlLabel: 'Seite',
|
||
noteLabel: 'Anmerkung',
|
||
placeholder: 'Was ist dir aufgefallen? Screenshots einfach hier einfügen (Strg+V).',
|
||
bold: 'Fett', italic: 'Kursiv', list: 'Liste',
|
||
attach: 'Datei anhängen',
|
||
save: 'Auf den Merkzettel',
|
||
saving: 'Speichere …',
|
||
savedHint: 'Gespeichert ✓',
|
||
pad: 'Merkzettel',
|
||
uploadTooBig: 'Datei ist größer als 20 MB.',
|
||
uploadFail: 'Upload fehlgeschlagen: ',
|
||
saveFail: 'Speichern fehlgeschlagen: ',
|
||
removeAttachment: 'Anhang entfernen'
|
||
}
|
||
};
|
||
var T = TEXTS[LANG];
|
||
|
||
// ---- API helpers --------------------------------------------------------
|
||
function api(path, options) {
|
||
options = options || {};
|
||
options.credentials = 'same-origin';
|
||
return fetch(API + path, options).then(function (response) {
|
||
return response.json();
|
||
}).then(function (result) {
|
||
if (result && result.error) { throw new Error(result.error); }
|
||
return result;
|
||
});
|
||
}
|
||
function apiJson(path, payload) {
|
||
return api(path, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
}
|
||
|
||
function el(tag, className, text) {
|
||
var node = document.createElement(tag);
|
||
if (className) { node.className = className; }
|
||
if (text) { node.textContent = text; }
|
||
return node;
|
||
}
|
||
|
||
// ---- state --------------------------------------------------------------
|
||
var serverState = null; // last GET state payload
|
||
var draftUid = 0; // feedback uid of the form's unsaved draft (0 = none)
|
||
var draftAttachments = []; // [{refUid, name}] of the draft
|
||
|
||
function itemCount() {
|
||
return serverState && serverState.summary ? serverState.summary.items.length : 0;
|
||
}
|
||
|
||
function refreshState() {
|
||
return api('state').then(function (state) {
|
||
serverState = state;
|
||
refreshBadge();
|
||
return state;
|
||
});
|
||
}
|
||
|
||
// ---- bootstrap: only build the UI for allowed users ----------------------
|
||
api('state').then(function (state) {
|
||
if (!state || !state.allowed) { return; }
|
||
serverState = state;
|
||
injectCss();
|
||
buildUi();
|
||
}).catch(function () { /* API unreachable - stay invisible */ });
|
||
|
||
function injectCss() {
|
||
var link = document.createElement('link');
|
||
link.rel = 'stylesheet';
|
||
link.href = CSS_URL;
|
||
document.head.appendChild(link);
|
||
}
|
||
|
||
// ---- UI -------------------------------------------------------------------
|
||
var root, toggle, badge, panel;
|
||
|
||
function buildUi() {
|
||
root = el('div', 'evofc');
|
||
root.setAttribute('data-evofc', '');
|
||
|
||
toggle = el('button', 'evofc-toggle');
|
||
toggle.type = 'button';
|
||
toggle.title = T.open;
|
||
toggle.setAttribute('aria-label', T.open);
|
||
toggle.innerHTML =
|
||
'<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">' +
|
||
'<path fill="currentColor" d="M4 3h16a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H8.4L4 21.6A1 1 0 0 1 2.4 20.8V5a2 2 0 0 1 2-2Zm2.6 5.2h11m-11 4h7"' +
|
||
' stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/></svg>';
|
||
badge = el('span', 'evofc-badge');
|
||
badge.hidden = true;
|
||
toggle.appendChild(badge);
|
||
|
||
panel = el('div', 'evofc-panel');
|
||
panel.hidden = true;
|
||
|
||
toggle.addEventListener('click', function () {
|
||
if (panel.hidden) {
|
||
renderForm();
|
||
panel.hidden = false;
|
||
} else {
|
||
panel.hidden = true;
|
||
}
|
||
});
|
||
|
||
root.appendChild(toggle);
|
||
root.appendChild(panel);
|
||
refreshBadge();
|
||
if (document.body) {
|
||
document.body.appendChild(root);
|
||
} else {
|
||
document.addEventListener('DOMContentLoaded', function () { document.body.appendChild(root); });
|
||
}
|
||
}
|
||
|
||
function refreshBadge() {
|
||
if (!badge) { return; }
|
||
var count = itemCount();
|
||
badge.textContent = String(count);
|
||
badge.hidden = count === 0;
|
||
}
|
||
|
||
function renderForm() {
|
||
panel.innerHTML = '';
|
||
panel.appendChild(el('div', 'evofc-title', T.formTitle));
|
||
|
||
panel.appendChild(el('label', 'evofc-label', T.urlLabel));
|
||
var urlInput = el('input', 'evofc-url');
|
||
urlInput.type = 'text';
|
||
urlInput.readOnly = true;
|
||
urlInput.value = location.href;
|
||
panel.appendChild(urlInput);
|
||
|
||
panel.appendChild(el('label', 'evofc-label', T.noteLabel));
|
||
|
||
// Mini toolbar + contenteditable rich-text area
|
||
var toolbar = el('div', 'evofc-toolbar');
|
||
var editor = el('div', 'evofc-rte');
|
||
editor.contentEditable = 'true';
|
||
editor.setAttribute('data-placeholder', T.placeholder);
|
||
[['bold', '<b>B</b>', T.bold],
|
||
['italic', '<i>I</i>', T.italic],
|
||
['insertUnorderedList', '☰', T.list]].forEach(function (def) {
|
||
var button = el('button', 'evofc-tool');
|
||
button.type = 'button';
|
||
button.innerHTML = def[1];
|
||
button.title = def[2];
|
||
// mousedown + preventDefault keeps the editor selection alive
|
||
button.addEventListener('mousedown', function (event) {
|
||
event.preventDefault();
|
||
editor.focus();
|
||
document.execCommand(def[0], false, null);
|
||
});
|
||
toolbar.appendChild(button);
|
||
});
|
||
panel.appendChild(toolbar);
|
||
panel.appendChild(editor);
|
||
|
||
var status = el('div', 'evofc-status');
|
||
var chips = el('div', 'evofc-chips');
|
||
panel.appendChild(chips);
|
||
renderChips(chips, status);
|
||
|
||
// Ctrl+V: image in the clipboard -> upload + inline <img>
|
||
editor.addEventListener('paste', function (event) {
|
||
var items = (event.clipboardData || {}).items || [];
|
||
for (var i = 0; i < items.length; i++) {
|
||
if (items[i].kind === 'file' && items[i].type.indexOf('image/') === 0) {
|
||
event.preventDefault();
|
||
var file = items[i].getAsFile();
|
||
uploadFile(file, status, chips, function (attachment) {
|
||
editor.focus();
|
||
document.execCommand('insertHTML', false,
|
||
'<img src="' + attachment.url + '" alt="' + attachment.name.replace(/"/g, '') + '">');
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Attach button + hidden file input
|
||
var fileInput = el('input');
|
||
fileInput.type = 'file';
|
||
fileInput.hidden = true;
|
||
fileInput.addEventListener('change', function () {
|
||
if (fileInput.files && fileInput.files[0]) {
|
||
uploadFile(fileInput.files[0], status, chips, null);
|
||
fileInput.value = '';
|
||
}
|
||
});
|
||
var attachButton = el('button', 'evofc-button', T.attach);
|
||
attachButton.type = 'button';
|
||
attachButton.addEventListener('click', function () { fileInput.click(); });
|
||
|
||
var saveButton = el('button', 'evofc-button evofc-primary', T.save);
|
||
saveButton.type = 'button';
|
||
var hint = el('span', 'evofc-hint');
|
||
saveButton.addEventListener('click', function () {
|
||
var note = editor.innerHTML;
|
||
var isEmpty = editor.textContent.trim() === '' && editor.querySelector('img') === null;
|
||
if (isEmpty && draftUid === 0) { editor.focus(); return; }
|
||
saveButton.disabled = true;
|
||
saveButton.textContent = T.saving;
|
||
var request = draftUid > 0
|
||
? apiJson('feedback/' + draftUid, { note: note, url: urlInput.value })
|
||
: apiJson('feedback', { note: note, url: urlInput.value });
|
||
request.then(function () {
|
||
draftUid = 0;
|
||
draftAttachments = [];
|
||
editor.innerHTML = '';
|
||
renderChips(chips, status);
|
||
return refreshState();
|
||
}).then(function () {
|
||
renderFooter(footer);
|
||
hint.textContent = T.savedHint;
|
||
setTimeout(function () { hint.textContent = ''; }, 1800);
|
||
}).catch(function (error) {
|
||
status.textContent = T.saveFail + error.message;
|
||
}).finally(function () {
|
||
saveButton.disabled = false;
|
||
saveButton.textContent = T.save;
|
||
});
|
||
});
|
||
|
||
var row = el('div', 'evofc-row');
|
||
row.appendChild(attachButton);
|
||
row.appendChild(saveButton);
|
||
row.appendChild(hint);
|
||
panel.appendChild(row);
|
||
panel.appendChild(fileInput);
|
||
panel.appendChild(status);
|
||
|
||
var footer = el('div', 'evofc-footer');
|
||
renderFooter(footer);
|
||
panel.appendChild(footer);
|
||
}
|
||
|
||
/** The draft row must exist before the first upload (uploads attach to it). */
|
||
function ensureDraft(urlValue) {
|
||
if (draftUid > 0) { return Promise.resolve(draftUid); }
|
||
return apiJson('feedback', { url: urlValue, note: '' }).then(function (result) {
|
||
draftUid = result.uid;
|
||
return refreshState().then(function () { return draftUid; });
|
||
});
|
||
}
|
||
|
||
function uploadFile(file, status, chips, onDone) {
|
||
if (!file) { return; }
|
||
if (file.size > MAX_UPLOAD) {
|
||
status.textContent = T.uploadTooBig;
|
||
return;
|
||
}
|
||
status.textContent = '';
|
||
ensureDraft(location.href).then(function (uid) {
|
||
var body = new FormData();
|
||
body.append('file', file, file.name || 'pasted-image.png');
|
||
body.append('feedback', String(uid));
|
||
return api('upload', { method: 'POST', body: body });
|
||
}).then(function (result) {
|
||
draftAttachments.push({ refUid: result.attachment.refUid, name: result.attachment.name });
|
||
renderChips(chips, status);
|
||
if (onDone) { onDone(result.attachment); }
|
||
}).catch(function (error) {
|
||
status.textContent = T.uploadFail + error.message;
|
||
});
|
||
}
|
||
|
||
function renderChips(chips, status) {
|
||
chips.innerHTML = '';
|
||
draftAttachments.forEach(function (attachment, index) {
|
||
var chip = el('span', 'evofc-chip');
|
||
chip.appendChild(el('span', 'evofc-chip-name', attachment.name));
|
||
var removeButton = el('button', 'evofc-remove', '×');
|
||
removeButton.type = 'button';
|
||
removeButton.title = T.removeAttachment;
|
||
removeButton.addEventListener('click', function () {
|
||
apiJson('attachment/' + attachment.refUid + '/delete', {}).then(function () {
|
||
draftAttachments.splice(index, 1);
|
||
renderChips(chips, status);
|
||
}).catch(function (error) {
|
||
status.textContent = error.message;
|
||
});
|
||
});
|
||
chip.appendChild(removeButton);
|
||
chips.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
function renderFooter(footer) {
|
||
footer.innerHTML = '';
|
||
var count = itemCount();
|
||
if (count === 0) { return; }
|
||
var padButton = el('button', 'evofc-button', T.pad + ' (' + count + ')');
|
||
padButton.type = 'button';
|
||
if (serverState.overviewUrl) {
|
||
padButton.addEventListener('click', function () {
|
||
location.href = serverState.overviewUrl;
|
||
});
|
||
} else {
|
||
padButton.disabled = true;
|
||
}
|
||
footer.appendChild(padButton);
|
||
}
|
||
})();
|