fe_comment: widget v2, overview link setting and inline mail images

- 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)
This commit is contained in:
orasche committed 2026-10-02 16:57:52 +02:00
1 parent 872c6e715c
commit d496877a53
6 files changed
+510 -217

No files matched your search

@@ -1,11 +1,15 @@
/**
* EXT:fe_comment — page feedback widget (framework-agnostic, vanilla JS).
* EXT:fe_comment v2 — page feedback widget (framework-agnostic, vanilla JS).
*
* One floating button at the right edge opens a small form: current page
* URL + a comment field. Saved notes land on a local note pad
* (localStorage, survives navigation — also SPA route changes, the URL is
* read at save time). The pad lists every note (URL + editable text) and
* submits all of them to POST /api/fe-comment/submit, which mails them.
* 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
@@ -16,42 +20,69 @@
if (window.__evofcLoaded) { return; }
window.__evofcLoaded = true;
var ENDPOINT = '/api/fe-comment/submit';
var API = '/api/fe-comment/';
var CSS_URL = '/api/fe-comment/widget.css';
var STORAGE_KEY = 'evoFeCommentNotes';
var MAX_UPLOAD = 20 * 1024 * 1024;
var T = {
open: 'Seite kommentieren',
formTitle: 'Anmerkung zu dieser Seite',
urlLabel: 'Seite',
commentLabel: 'Anmerkung',
placeholder: 'Was ist dir aufgefallen?',
save: 'Auf den Merkzettel',
showPad: 'Merkzettel',
padTitle: 'Merkzettel',
padEmpty: 'Noch keine Anmerkungen.',
remove: 'Entfernen',
submit: 'Absenden',
submitting: 'Sende …',
back: 'Zurück',
savedHint: 'Gespeichert ✓',
sentOk: 'Danke! Die Anmerkungen wurden gesendet.',
sentFail: 'Senden fehlgeschlagen: '
// ---- 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];
function loadNotes() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
var list = raw ? JSON.parse(raw) : [];
return Array.isArray(list) ? list : [];
} catch (e) { return []; }
// ---- 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 saveNotes(notes) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(notes)); } catch (e) { /* private mode */ }
}
function currentUrl() {
return location.pathname + location.search;
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; }
@@ -59,200 +90,265 @@
return node;
}
// ---- styles ------------------------------------------------------------
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = CSS_URL;
document.head.appendChild(link);
// ---- 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
// ---- structure ---------------------------------------------------------
var root = el('div', 'evofc');
root.setAttribute('data-evofc', '');
function itemCount() {
return serverState && serverState.summary ? serverState.summary.items.length : 0;
}
var 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>';
var badge = el('span', 'evofc-badge');
badge.hidden = true;
toggle.appendChild(badge);
function refreshState() {
return api('state').then(function (state) {
serverState = state;
refreshBadge();
return state;
});
}
var panel = el('div', 'evofc-panel');
panel.hidden = true;
// ---- 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 */ });
root.appendChild(toggle);
root.appendChild(panel);
function injectCss() {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = CSS_URL;
document.head.appendChild(link);
}
// ---- views -------------------------------------------------------------
var view = 'form'; // 'form' | 'pad'
// ---- 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() {
var count = loadNotes().length;
if (!badge) { return; }
var count = itemCount();
badge.textContent = String(count);
badge.hidden = count === 0;
}
function renderForm() {
view = 'form';
panel.innerHTML = '';
panel.appendChild(el('div', 'evofc-title', T.formTitle));
var urlLabel = el('label', 'evofc-label', T.urlLabel);
panel.appendChild(el('label', 'evofc-label', T.urlLabel));
var urlInput = el('input', 'evofc-url');
urlInput.type = 'text';
urlInput.readOnly = true;
urlInput.value = currentUrl();
urlInput.value = location.href;
panel.appendChild(urlInput);
var commentLabel = el('label', 'evofc-label', T.commentLabel);
var textarea = el('textarea', 'evofc-text');
textarea.rows = 4;
textarea.placeholder = T.placeholder;
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', '&#9776;', 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 comment = textarea.value.trim();
if (comment === '') { textarea.focus(); return; }
var notes = loadNotes();
notes.push({ url: urlInput.value, comment: comment, added: Date.now() });
saveNotes(notes);
textarea.value = '';
refreshBadge();
renderFooter(footer);
hint.textContent = T.savedHint;
setTimeout(function () { hint.textContent = ''; }, 1800);
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 footer = el('div', 'evofc-footer');
renderFooter(footer);
panel.appendChild(urlLabel);
panel.appendChild(urlInput);
panel.appendChild(commentLabel);
panel.appendChild(textarea);
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 = loadNotes().length;
if (count > 0) {
var padButton = el('button', 'evofc-button', T.showPad + ' (' + count + ')');
padButton.type = 'button';
padButton.addEventListener('click', renderPad);
footer.appendChild(padButton);
}
}
function renderPad() {
view = 'pad';
panel.innerHTML = '';
panel.appendChild(el('div', 'evofc-title', T.padTitle));
var notes = loadNotes();
if (notes.length === 0) {
panel.appendChild(el('p', 'evofc-empty', T.padEmpty));
}
var list = el('div', 'evofc-list');
notes.forEach(function (note, index) {
var item = el('div', 'evofc-item');
var head = el('div', 'evofc-item-head');
head.appendChild(el('span', 'evofc-item-url', note.url || '(ohne URL)'));
var removeButton = el('button', 'evofc-remove', '×');
removeButton.type = 'button';
removeButton.title = T.remove;
removeButton.addEventListener('click', function () {
var current = loadNotes();
current.splice(index, 1);
saveNotes(current);
refreshBadge();
renderPad();
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;
});
head.appendChild(removeButton);
var textarea = el('textarea', 'evofc-text');
textarea.rows = 3;
textarea.value = note.comment;
textarea.addEventListener('input', function () {
var current = loadNotes();
if (current[index]) {
current[index].comment = textarea.value;
saveNotes(current);
}
});
item.appendChild(head);
item.appendChild(textarea);
list.appendChild(item);
});
panel.appendChild(list);
var status = el('p', 'evofc-status');
var row = el('div', 'evofc-row');
var backButton = el('button', 'evofc-button', T.back);
backButton.type = 'button';
backButton.addEventListener('click', renderForm);
row.appendChild(backButton);
if (notes.length > 0) {
var submitButton = el('button', 'evofc-button evofc-primary', T.submit);
submitButton.type = 'button';
submitButton.addEventListener('click', function () {
submitButton.disabled = true;
submitButton.textContent = T.submitting;
var entries = loadNotes().map(function (note) {
return { url: note.url, comment: note.comment };
});
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entries: entries })
}).then(function (response) { return response.json(); }).then(function (result) {
if (result && result.success) {
saveNotes([]);
refreshBadge();
renderPad();
status.textContent = T.sentOk;
panel.appendChild(status);
} else {
throw new Error((result && result.error) || 'unknown');
}
}).catch(function (error) {
submitButton.disabled = false;
submitButton.textContent = T.submit;
status.textContent = T.sentFail + error.message;
panel.appendChild(status);
});
});
row.appendChild(submitButton);
}
panel.appendChild(row);
}
toggle.addEventListener('click', function () {
if (panel.hidden) {
renderForm();
panel.hidden = false;
} else {
panel.hidden = true;
padButton.disabled = true;
}
});
refreshBadge();
if (document.body) {
document.body.appendChild(root);
} else {
document.addEventListener('DOMContentLoaded', function () { document.body.appendChild(root); });
footer.appendChild(padButton);
}
})();