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:
1 parent
872c6e715c
commit
d496877a53
6 files changed
+510
-217
No files matched your search
@@ -120,10 +120,28 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
|
|||||||
'allowed' => true,
|
'allowed' => true,
|
||||||
'user' => ['name' => $user['name'], 'email' => $user['email']],
|
'user' => ['name' => $user['name'], 'email' => $user['email']],
|
||||||
'recipients' => $this->recipientOptions(),
|
'recipients' => $this->recipientOptions(),
|
||||||
|
'overviewUrl' => $this->overviewUrl(),
|
||||||
'summary' => $summary === null ? null : $this->summaryPayload($summary),
|
'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 */
|
/** @param array{uid:int,username:string,name:string,email:string} $user */
|
||||||
private function createFeedback(ServerRequestInterface $request, array $user): ResponseInterface
|
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);
|
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 = '<h2>Page feedback from ' . htmlspecialchars($user['name']) . '</h2>'
|
$html = '<h2>Page feedback from ' . htmlspecialchars($user['name']) . '</h2>'
|
||||||
. '<p><strong>Author:</strong> ' . htmlspecialchars($user['name'])
|
. '<p><strong>Author:</strong> ' . htmlspecialchars($user['name'])
|
||||||
. ' <' . htmlspecialchars($user['email']) . '></p>';
|
. ' <' . htmlspecialchars($user['email']) . '></p>';
|
||||||
if (trim(strip_tags((string)$payload['generalNotes'])) !== '') {
|
if (trim(strip_tags((string)$payload['generalNotes'])) !== '') {
|
||||||
$html .= '<h3>General notes</h3>' . $payload['generalNotes'];
|
$html .= '<h3>General notes</h3>' . $this->embedNoteImages((string)$payload['generalNotes'], $embeds);
|
||||||
}
|
}
|
||||||
foreach ($payload['items'] as $i => $item) {
|
foreach ($payload['items'] as $i => $item) {
|
||||||
$html .= '<hr><h3>' . ($i + 1) . ') ' . htmlspecialchars($item['url'] ?: '(no url)') . '</h3>' . $item['note'];
|
$html .= '<hr><h3>' . ($i + 1) . ') ' . htmlspecialchars($item['url'] ?: '(no url)') . '</h3>'
|
||||||
|
. $this->embedNoteImages((string)$item['note'], $embeds);
|
||||||
if ($item['attachments'] !== []) {
|
if ($item['attachments'] !== []) {
|
||||||
$html .= '<p><strong>Attachments:</strong></p><ul>';
|
$html .= '<p><strong>Attachments:</strong></p><ul>';
|
||||||
foreach ($item['attachments'] as $attachment) {
|
foreach ($item['attachments'] as $attachment) {
|
||||||
$base = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'dev.vitec.com');
|
|
||||||
$html .= '<li><a href="' . htmlspecialchars($base . $attachment['url']) . '">' . htmlspecialchars($attachment['name']) . '</a></li>';
|
$html .= '<li><a href="' . htmlspecialchars($base . $attachment['url']) . '">' . htmlspecialchars($attachment['name']) . '</a></li>';
|
||||||
}
|
}
|
||||||
$html .= '</ul>';
|
$html .= '</ul>';
|
||||||
@@ -297,6 +321,9 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$mail = GeneralUtility::makeInstance(MailMessage::class);
|
$mail = GeneralUtility::makeInstance(MailMessage::class);
|
||||||
|
foreach ($embeds as $cid => $absolutePath) {
|
||||||
|
$mail->embedFromPath($absolutePath, $cid);
|
||||||
|
}
|
||||||
$mail->to(...$to)
|
$mail->to(...$to)
|
||||||
->subject($this->settings->subject() . ' — ' . $user['name'] . ' (' . count($payload['items']) . ' item' . (count($payload['items']) === 1 ? '' : 's') . ')')
|
->subject($this->settings->subject() . ' — ' . $user['name'] . ' (' . count($payload['items']) . ' item' . (count($payload['items']) === 1 ? '' : 's') . ')')
|
||||||
->html($html);
|
->html($html);
|
||||||
@@ -318,6 +345,33 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
|
|||||||
|
|
||||||
// ---- helpers ------------------------------------------------------------
|
// ---- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites <img src="/fileadmin/..."> 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<string,string> $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<string,mixed>|null */
|
/** @return array<string,mixed>|null */
|
||||||
private function openSummary(int $userUid, bool $createIfMissing): ?array
|
private function openSummary(int $userUid, bool $createIfMissing): ?array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ final class ExtensionSettings
|
|||||||
return (int)($this->settings['storagePid'] ?? 0);
|
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 <mail>; Label2 <mail2>".
|
* Selectable recipients, parsed from "Label <mail>; Label2 <mail2>".
|
||||||
* The index doubles as the id the widget sends back.
|
* The index doubles as the id the widget sends back.
|
||||||
|
|||||||
@@ -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 {
|
.evofc {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
right: 52px;
|
right: 52px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 320px;
|
width: 340px;
|
||||||
max-width: calc(100vw - 70px);
|
max-width: calc(100vw - 70px);
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -64,21 +64,74 @@
|
|||||||
.evofc-title { font-weight: 700; margin-bottom: 10px; }
|
.evofc-title { font-weight: 700; margin-bottom: 10px; }
|
||||||
.evofc-label { display: block; font-size: 12px; color: #5a6572; margin: 8px 0 2px; }
|
.evofc-label { display: block; font-size: 12px; color: #5a6572; margin: 8px 0 2px; }
|
||||||
|
|
||||||
.evofc-url,
|
.evofc-url {
|
||||||
.evofc-text {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid #c9d1da;
|
border: 1px solid #c9d1da;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
background: #f3f5f7;
|
||||||
background: #fff;
|
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 {
|
.evofc-button {
|
||||||
border: 1px solid #c9d1da;
|
border: 1px solid #c9d1da;
|
||||||
@@ -91,25 +144,16 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.evofc-button:hover { background: #eef1f4; }
|
.evofc-button:hover { background: #eef1f4; }
|
||||||
|
.evofc-button:disabled { opacity: .6; cursor: default; }
|
||||||
.evofc-primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
|
.evofc-primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
|
||||||
.evofc-primary:hover { background: #1a5fd0; }
|
.evofc-primary:hover { background: #1a5fd0; }
|
||||||
.evofc-primary:disabled { opacity: .6; cursor: default; }
|
.evofc-primary:disabled { opacity: .6; cursor: default; }
|
||||||
|
|
||||||
.evofc-hint { color: #2f9e44; font-size: 12px; }
|
.evofc-hint { color: #2f9e44; font-size: 12px; }
|
||||||
.evofc-footer { margin-top: 10px; }
|
.evofc-footer { margin-top: 10px; }
|
||||||
.evofc-empty, .evofc-status { margin: 8px 0 0; font-size: 13px; }
|
.evofc-status { margin: 8px 0 0; font-size: 13px; font-weight: 600; color: #d9463e; }
|
||||||
.evofc-status { font-weight: 600; }
|
.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 {
|
.evofc-remove {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -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
|
* Server-backed: everything lives in the fe_comment API (/api/fe-comment/),
|
||||||
* URL + a comment field. Saved notes land on a local note pad
|
* bound to the logged-in FE user. The widget only renders for users the API
|
||||||
* (localStorage, survives navigation — also SPA route changes, the URL is
|
* accepts (GET state -> allowed) — for everyone else it stays completely
|
||||||
* read at save time). The pad lists every note (URL + editable text) and
|
* invisible, so the script tag is safe on public pages.
|
||||||
* submits all of them to POST /api/fe-comment/submit, which mails them.
|
*
|
||||||
|
* 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>
|
* Integration: <script src="/api/fe-comment/widget.js" defer></script>
|
||||||
* (classic TYPO3 sites get this injected by the extension automatically
|
* (classic TYPO3 sites get this injected by the extension automatically
|
||||||
@@ -16,42 +20,69 @@
|
|||||||
if (window.__evofcLoaded) { return; }
|
if (window.__evofcLoaded) { return; }
|
||||||
window.__evofcLoaded = true;
|
window.__evofcLoaded = true;
|
||||||
|
|
||||||
var ENDPOINT = '/api/fe-comment/submit';
|
var API = '/api/fe-comment/';
|
||||||
var CSS_URL = '/api/fe-comment/widget.css';
|
var CSS_URL = '/api/fe-comment/widget.css';
|
||||||
var STORAGE_KEY = 'evoFeCommentNotes';
|
var MAX_UPLOAD = 20 * 1024 * 1024;
|
||||||
|
|
||||||
var T = {
|
// ---- 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',
|
open: 'Seite kommentieren',
|
||||||
formTitle: 'Anmerkung zu dieser Seite',
|
formTitle: 'Anmerkung zu dieser Seite',
|
||||||
urlLabel: 'Seite',
|
urlLabel: 'Seite',
|
||||||
commentLabel: 'Anmerkung',
|
noteLabel: 'Anmerkung',
|
||||||
placeholder: 'Was ist dir aufgefallen?',
|
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',
|
save: 'Auf den Merkzettel',
|
||||||
showPad: 'Merkzettel',
|
saving: 'Speichere …',
|
||||||
padTitle: 'Merkzettel',
|
|
||||||
padEmpty: 'Noch keine Anmerkungen.',
|
|
||||||
remove: 'Entfernen',
|
|
||||||
submit: 'Absenden',
|
|
||||||
submitting: 'Sende …',
|
|
||||||
back: 'Zurück',
|
|
||||||
savedHint: 'Gespeichert ✓',
|
savedHint: 'Gespeichert ✓',
|
||||||
sentOk: 'Danke! Die Anmerkungen wurden gesendet.',
|
pad: 'Merkzettel',
|
||||||
sentFail: 'Senden fehlgeschlagen: '
|
uploadTooBig: 'Datei ist größer als 20 MB.',
|
||||||
|
uploadFail: 'Upload fehlgeschlagen: ',
|
||||||
|
saveFail: 'Speichern fehlgeschlagen: ',
|
||||||
|
removeAttachment: 'Anhang entfernen'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
var T = TEXTS[LANG];
|
||||||
|
|
||||||
function loadNotes() {
|
// ---- API helpers --------------------------------------------------------
|
||||||
try {
|
function api(path, options) {
|
||||||
var raw = localStorage.getItem(STORAGE_KEY);
|
options = options || {};
|
||||||
var list = raw ? JSON.parse(raw) : [];
|
options.credentials = 'same-origin';
|
||||||
return Array.isArray(list) ? list : [];
|
return fetch(API + path, options).then(function (response) {
|
||||||
} catch (e) { return []; }
|
return response.json();
|
||||||
|
}).then(function (result) {
|
||||||
|
if (result && result.error) { throw new Error(result.error); }
|
||||||
|
return result;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function saveNotes(notes) {
|
function apiJson(path, payload) {
|
||||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(notes)); } catch (e) { /* private mode */ }
|
return api(path, {
|
||||||
}
|
method: 'POST',
|
||||||
function currentUrl() {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
return location.pathname + location.search;
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function el(tag, className, text) {
|
function el(tag, className, text) {
|
||||||
var node = document.createElement(tag);
|
var node = document.createElement(tag);
|
||||||
if (className) { node.className = className; }
|
if (className) { node.className = className; }
|
||||||
@@ -59,17 +90,46 @@
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- styles ------------------------------------------------------------
|
// ---- 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');
|
var link = document.createElement('link');
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
link.href = CSS_URL;
|
link.href = CSS_URL;
|
||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- structure ---------------------------------------------------------
|
// ---- UI -------------------------------------------------------------------
|
||||||
var root = el('div', 'evofc');
|
var root, toggle, badge, panel;
|
||||||
|
|
||||||
|
function buildUi() {
|
||||||
|
root = el('div', 'evofc');
|
||||||
root.setAttribute('data-evofc', '');
|
root.setAttribute('data-evofc', '');
|
||||||
|
|
||||||
var toggle = el('button', 'evofc-toggle');
|
toggle = el('button', 'evofc-toggle');
|
||||||
toggle.type = 'button';
|
toggle.type = 'button';
|
||||||
toggle.title = T.open;
|
toggle.title = T.open;
|
||||||
toggle.setAttribute('aria-label', T.open);
|
toggle.setAttribute('aria-label', T.open);
|
||||||
@@ -77,169 +137,13 @@
|
|||||||
'<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">' +
|
'<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"' +
|
'<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>';
|
' stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/></svg>';
|
||||||
var badge = el('span', 'evofc-badge');
|
badge = el('span', 'evofc-badge');
|
||||||
badge.hidden = true;
|
badge.hidden = true;
|
||||||
toggle.appendChild(badge);
|
toggle.appendChild(badge);
|
||||||
|
|
||||||
var panel = el('div', 'evofc-panel');
|
panel = el('div', 'evofc-panel');
|
||||||
panel.hidden = true;
|
panel.hidden = true;
|
||||||
|
|
||||||
root.appendChild(toggle);
|
|
||||||
root.appendChild(panel);
|
|
||||||
|
|
||||||
// ---- views -------------------------------------------------------------
|
|
||||||
var view = 'form'; // 'form' | 'pad'
|
|
||||||
|
|
||||||
function refreshBadge() {
|
|
||||||
var count = loadNotes().length;
|
|
||||||
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);
|
|
||||||
var urlInput = el('input', 'evofc-url');
|
|
||||||
urlInput.type = 'text';
|
|
||||||
urlInput.readOnly = true;
|
|
||||||
urlInput.value = currentUrl();
|
|
||||||
|
|
||||||
var commentLabel = el('label', 'evofc-label', T.commentLabel);
|
|
||||||
var textarea = el('textarea', 'evofc-text');
|
|
||||||
textarea.rows = 4;
|
|
||||||
textarea.placeholder = T.placeholder;
|
|
||||||
|
|
||||||
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 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(saveButton);
|
|
||||||
row.appendChild(hint);
|
|
||||||
panel.appendChild(row);
|
|
||||||
panel.appendChild(footer);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
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 () {
|
toggle.addEventListener('click', function () {
|
||||||
if (panel.hidden) {
|
if (panel.hidden) {
|
||||||
renderForm();
|
renderForm();
|
||||||
@@ -249,10 +153,202 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
root.appendChild(toggle);
|
||||||
|
root.appendChild(panel);
|
||||||
refreshBadge();
|
refreshBadge();
|
||||||
if (document.body) {
|
if (document.body) {
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
} else {
|
} else {
|
||||||
document.addEventListener('DOMContentLoaded', function () { document.body.appendChild(root); });
|
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);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
@@ -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.
|
# cat=basic//20; type=string; label=Storage pid: page/folder uid the feedback records are stored on (backend visibility). 0 = root.
|
||||||
storagePid = 0
|
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
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!-- fe_comment v2 Testseite: FE-Login (Referenz-Implementierung der React-
|
||||||
|
Strecke) + Widget-Einbindung. Nur fuer Dev-Tests, kann jederzeit weg. -->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>fe_comment widget test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif; max-width: 640px; margin: 40px auto; padding: 0 16px; color: #1c2430; }
|
||||||
|
h1 { font-size: 20px; }
|
||||||
|
label { display: block; margin: 10px 0 2px; font-size: 13px; color: #5a6572; }
|
||||||
|
input { padding: 6px 8px; border: 1px solid #c9d1da; border-radius: 6px; font: inherit; width: 240px; }
|
||||||
|
button { margin-top: 12px; padding: 7px 14px; border: 1px solid #1f6feb; border-radius: 6px; background: #1f6feb; color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
|
||||||
|
#status { margin-top: 14px; font-weight: 600; }
|
||||||
|
.ok { color: #2f9e44; } .err { color: #d9463e; }
|
||||||
|
p.hint { color: #5a6572; font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>fe_comment v2 — Widget-Testseite</h1>
|
||||||
|
<p class="hint">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).</p>
|
||||||
|
|
||||||
|
<div id="login">
|
||||||
|
<label>User</label><input id="user" autocomplete="username">
|
||||||
|
<label>Passwort</label><input id="pass" type="password" autocomplete="current-password">
|
||||||
|
<button id="loginBtn">Login</button>
|
||||||
|
<button id="logoutBtn" style="background:#f6f8fa;color:#1c2430;border-color:#c9d1da">Logout</button>
|
||||||
|
</div>
|
||||||
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var statusBox = document.getElementById('status');
|
||||||
|
function say(text, ok) {
|
||||||
|
statusBox.textContent = text;
|
||||||
|
statusBox.className = ok ? 'ok' : 'err';
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkState() {
|
||||||
|
return fetch('/api/fe-comment/state', { credentials: 'same-origin' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (s) {
|
||||||
|
if (s.allowed) { say('Eingeloggt als ' + s.user.name + ' - Widget aktiv (rechts).', true); }
|
||||||
|
else { say('Nicht eingeloggt / nicht berechtigt (allowed:false).', false); }
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die React-Login-Strecke in Kurzform:
|
||||||
|
// 1. Seite als JSON holen -> felogin-Element -> form (setzt Nonce-Cookie)
|
||||||
|
// 2. Alle Formfelder + user/pass urlencoded an '/_json' + form.action posten
|
||||||
|
function login(user, pass) {
|
||||||
|
return fetch('/_json/fe-user-profile', { credentials: 'same-origin' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (page) {
|
||||||
|
var el = (page.content.colPos0 || []).filter(function (e) { return e.type === 'felogin_login'; })[0];
|
||||||
|
if (!el || !el.content || !el.content.data.form) { throw new Error('felogin-Formular nicht im JSON'); }
|
||||||
|
var form = el.content.data.form;
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
form.elements.forEach(function (f) { if (f.name) { body.set(f.name, f.value || ''); } });
|
||||||
|
body.set('user', user);
|
||||||
|
body.set('pass', pass);
|
||||||
|
return fetch('/_json' + form.action, { method: 'POST', body: body, credentials: 'same-origin' });
|
||||||
|
})
|
||||||
|
.then(checkState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
body.set('logintype', 'logout');
|
||||||
|
return fetch('/_json/fe-user-profile', { method: 'POST', body: body, credentials: 'same-origin' })
|
||||||
|
.then(function () { location.reload(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('loginBtn').addEventListener('click', function () {
|
||||||
|
login(document.getElementById('user').value, document.getElementById('pass').value)
|
||||||
|
.then(function (s) { if (s.allowed) { location.reload(); } })
|
||||||
|
.catch(function (e) { say('Fehler: ' + e.message, false); });
|
||||||
|
});
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||||
|
|
||||||
|
checkState();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="/api/fe-comment/widget.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user