/** * 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: * (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 = ''; 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', T.bold], ['italic', '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 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, '' + attachment.name.replace(/'); }); 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); } })();