diff --git a/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php b/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php index ed2fd34..c83cced 100644 --- a/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php +++ b/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php @@ -120,10 +120,28 @@ final class FeedbackApiMiddleware implements MiddlewareInterface 'allowed' => true, 'user' => ['name' => $user['name'], 'email' => $user['email']], 'recipients' => $this->recipientOptions(), + 'overviewUrl' => $this->overviewUrl(), 'summary' => $summary === null ? null : $this->summaryPayload($summary), ]); } + /** + * Visitor-facing URL of the feedback overview page (extension setting + * overviewPid), resolved via the page slug. Empty string when unset. + */ + private function overviewUrl(): string + { + $pid = $this->settings->overviewPid(); + if ($pid <= 0) { + return ''; + } + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); + $slug = $queryBuilder->select('slug')->from('pages') + ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($pid, \Doctrine\DBAL\ParameterType::INTEGER))) + ->executeQuery()->fetchOne(); + return is_string($slug) ? $slug : ''; + } + /** @param array{uid:int,username:string,name:string,email:string} $user */ private function createFeedback(ServerRequestInterface $request, array $user): ResponseInterface { @@ -278,18 +296,24 @@ final class FeedbackApiMiddleware implements MiddlewareInterface return new JsonResponse(['error' => 'no recipient configured'], 503); } + // Inline images (Ctrl+V pastes) are embedded into the mail itself: + // the notes reference /fileadmin/... which a mail client cannot load + // (relative, and dev sits behind basic auth). $embeds collects + // cid => absolute file path while the HTML is assembled. + $embeds = []; + $base = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'dev.vitec.com'); $html = '

Page feedback from ' . htmlspecialchars($user['name']) . '

' . '

Author: ' . htmlspecialchars($user['name']) . ' <' . htmlspecialchars($user['email']) . '>

'; if (trim(strip_tags((string)$payload['generalNotes'])) !== '') { - $html .= '

General notes

' . $payload['generalNotes']; + $html .= '

General notes

' . $this->embedNoteImages((string)$payload['generalNotes'], $embeds); } foreach ($payload['items'] as $i => $item) { - $html .= '

' . ($i + 1) . ') ' . htmlspecialchars($item['url'] ?: '(no url)') . '

' . $item['note']; + $html .= '

' . ($i + 1) . ') ' . htmlspecialchars($item['url'] ?: '(no url)') . '

' + . $this->embedNoteImages((string)$item['note'], $embeds); if ($item['attachments'] !== []) { $html .= '

Attachments:

'; @@ -297,6 +321,9 @@ final class FeedbackApiMiddleware implements MiddlewareInterface } $mail = GeneralUtility::makeInstance(MailMessage::class); + foreach ($embeds as $cid => $absolutePath) { + $mail->embedFromPath($absolutePath, $cid); + } $mail->to(...$to) ->subject($this->settings->subject() . ' — ' . $user['name'] . ' (' . count($payload['items']) . ' item' . (count($payload['items']) === 1 ? '' : 's') . ')') ->html($html); @@ -318,6 +345,33 @@ final class FeedbackApiMiddleware implements MiddlewareInterface // ---- helpers ------------------------------------------------------------ + /** + * Rewrites in a note to cid: references and + * collects the embeds (cid => absolute path) for the mail. Images whose + * file is gone keep their URL made absolute, so at least the link works. + * + * @param array $embeds + */ + private function embedNoteImages(string $noteHtml, array &$embeds): string + { + return (string)preg_replace_callback( + '/src="(\/fileadmin\/[^"]+)"/', + function (array $match) use (&$embeds): string { + $absolute = \TYPO3\CMS\Core\Core\Environment::getPublicPath() . rawurldecode($match[1]); + if (!is_file($absolute)) { + return 'src="https://' . ($_SERVER['HTTP_HOST'] ?? 'dev.vitec.com') . $match[1] . '"'; + } + $cid = array_search($absolute, $embeds, true); + if ($cid === false) { + $cid = 'fecimg' . (count($embeds) + 1); + $embeds[$cid] = $absolute; + } + return 'src="cid:' . $cid . '"'; + }, + $noteHtml + ); + } + /** @return array|null */ private function openSummary(int $userUid, bool $createIfMissing): ?array { diff --git a/packages/fe_comment/Classes/Service/ExtensionSettings.php b/packages/fe_comment/Classes/Service/ExtensionSettings.php index 7a9223d..43509ed 100644 --- a/packages/fe_comment/Classes/Service/ExtensionSettings.php +++ b/packages/fe_comment/Classes/Service/ExtensionSettings.php @@ -68,6 +68,12 @@ final class ExtensionSettings return (int)($this->settings['storagePid'] ?? 0); } + /** Page uid of the feedback overview page; 0 = no overview link. */ + public function overviewPid(): int + { + return (int)($this->settings['overviewPid'] ?? 0); + } + /** * Selectable recipients, parsed from "Label ; Label2 ". * The index doubles as the id the widget sends back. diff --git a/packages/fe_comment/Resources/Public/Css/fe-comment.css b/packages/fe_comment/Resources/Public/Css/fe-comment.css index 5c1c3cc..77079f9 100644 --- a/packages/fe_comment/Resources/Public/Css/fe-comment.css +++ b/packages/fe_comment/Resources/Public/Css/fe-comment.css @@ -1,4 +1,4 @@ -/* EXT:fe_comment widget — isolated styles, .evofc-prefixed, high z-index. */ +/* EXT:fe_comment v2 widget — isolated styles, .evofc-prefixed, high z-index. */ .evofc { position: fixed; @@ -50,7 +50,7 @@ right: 52px; top: 50%; transform: translateY(-50%); - width: 320px; + width: 340px; max-width: calc(100vw - 70px); max-height: 80vh; overflow: auto; @@ -64,21 +64,74 @@ .evofc-title { font-weight: 700; margin-bottom: 10px; } .evofc-label { display: block; font-size: 12px; color: #5a6572; margin: 8px 0 2px; } -.evofc-url, -.evofc-text { +.evofc-url { width: 100%; box-sizing: border-box; border: 1px solid #c9d1da; border-radius: 6px; padding: 6px 8px; font: inherit; - color: inherit; - background: #fff; + background: #f3f5f7; + color: #5a6572; } -.evofc-url { background: #f3f5f7; color: #5a6572; } -.evofc-text { resize: vertical; } -.evofc-row { display: flex; gap: 8px; align-items: center; margin-top: 10px; } +/* Mini toolbar + rich-text area */ +.evofc-toolbar { display: flex; gap: 4px; margin-bottom: 4px; } +.evofc-tool { + min-width: 28px; + height: 26px; + border: 1px solid #c9d1da; + border-radius: 5px; + background: #f6f8fa; + color: #1c2430; + font-size: 13px; + cursor: pointer; + padding: 0 6px; +} +.evofc-tool:hover { background: #eef1f4; } + +.evofc-rte { + width: 100%; + box-sizing: border-box; + min-height: 96px; + max-height: 260px; + overflow: auto; + border: 1px solid #c9d1da; + border-radius: 6px; + padding: 6px 8px; + background: #fff; + outline: none; +} +.evofc-rte:focus { border-color: #1f6feb; } +.evofc-rte:empty::before { + content: attr(data-placeholder); + color: #9aa4af; + pointer-events: none; +} +.evofc-rte img { max-width: 100%; height: auto; border-radius: 4px; } +.evofc-rte ul { margin: 4px 0; padding-left: 20px; } + +/* Attachment chips */ +.evofc-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } +.evofc-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + border: 1px solid #e2e7ec; + border-radius: 12px; + background: #f6f8fa; + padding: 2px 6px 2px 10px; + font-size: 12px; +} +.evofc-chip-name { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.evofc-row { display: flex; gap: 8px; align-items: center; margin-top: 10px; flex-wrap: wrap; } .evofc-button { border: 1px solid #c9d1da; @@ -91,25 +144,16 @@ cursor: pointer; } .evofc-button:hover { background: #eef1f4; } +.evofc-button:disabled { opacity: .6; cursor: default; } .evofc-primary { background: #1f6feb; border-color: #1f6feb; color: #fff; } .evofc-primary:hover { background: #1a5fd0; } .evofc-primary:disabled { opacity: .6; cursor: default; } .evofc-hint { color: #2f9e44; font-size: 12px; } .evofc-footer { margin-top: 10px; } -.evofc-empty, .evofc-status { margin: 8px 0 0; font-size: 13px; } -.evofc-status { font-weight: 600; } +.evofc-status { margin: 8px 0 0; font-size: 13px; font-weight: 600; color: #d9463e; } +.evofc-status:empty { display: none; } -.evofc-list { display: flex; flex-direction: column; gap: 10px; } -.evofc-item { border: 1px solid #e2e7ec; border-radius: 8px; padding: 8px; } -.evofc-item-head { display: flex; justify-content: space-between; align-items: center; gap: 6px; margin-bottom: 6px; } -.evofc-item-url { - font-size: 12px; - color: #5a6572; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} .evofc-remove { border: 0; background: none; diff --git a/packages/fe_comment/Resources/Public/JavaScript/fe-comment.js b/packages/fe_comment/Resources/Public/JavaScript/fe-comment.js index b90b1a8..3db0d11 100644 --- a/packages/fe_comment/Resources/Public/JavaScript/fe-comment.js +++ b/packages/fe_comment/Resources/Public/JavaScript/fe-comment.js @@ -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: * (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 = - ''; - 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 = + ''; + 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', 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 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); } })(); diff --git a/packages/fe_comment/ext_conf_template.txt b/packages/fe_comment/ext_conf_template.txt index deff794..768f176 100644 --- a/packages/fe_comment/ext_conf_template.txt +++ b/packages/fe_comment/ext_conf_template.txt @@ -24,3 +24,6 @@ recipients = # cat=basic//20; type=string; label=Storage pid: page/folder uid the feedback records are stored on (backend visibility). 0 = root. storagePid = 0 + +# cat=basic//30; type=string; label=Overview page (uid): page showing the feedback overview; its URL is delivered to the widget as the note pad link. 0 = no link. +overviewPid = 0 diff --git a/public/widget-test.html b/public/widget-test.html new file mode 100644 index 0000000..58474db --- /dev/null +++ b/public/widget-test.html @@ -0,0 +1,90 @@ + + + + + + +fe_comment widget test + + + +

fe_comment v2 — Widget-Testseite

+

Diese Seite tut, was spaeter die React-App tut: felogin-Formular +per JSON holen (inkl. CSRF-Token), Login-POST, dann erscheint rechts das +Feedback-Widget (nur fuer berechtigte FE-User).

+ +
+ + + + +
+
+ + + + +