Files
VITEC-website/packages/fe_comment/Resources/Public/JavaScript/fe-comment.js
T
orasche d496877a53 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)
2026-10-02 16:57:52 +02:00

355 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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', '&#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 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);
}
})();