192 lines
7.7 KiB
JavaScript
192 lines
7.7 KiB
JavaScript
/**
|
|
* otel-todo.js - file todos / issues straight from the page.
|
|
*
|
|
* Shows a small floating symbol and listens for a keyboard shortcut. Either
|
|
* opens a native <dialog> in the bottom-right corner with a text box and an
|
|
* optional component picker, and POSTs the note (with the current path and
|
|
* hash) to the collector's /v1/todos endpoint.
|
|
*
|
|
* <script src="https://otel.example.com/static/otel-todo.js"
|
|
* data-service-name="checkout-web"
|
|
* data-ingest-key="..."
|
|
* data-components="Header,Cart,Payment"></script>
|
|
*
|
|
* Attributes (or OtelTodo.init({...}) keys): data-base-url, data-service-name
|
|
* (the project), data-ingest-key, data-components (comma list, also
|
|
* discovered from [data-component] elements on the page), data-shortcut
|
|
* (default "alt+shift+t"), data-todo-endpoint, data-hide-button="true".
|
|
*/
|
|
(function (global) {
|
|
'use strict';
|
|
var DEFAULT_BASE_URL = 'https://otel.k6n.net';
|
|
var scriptEl = document.currentScript;
|
|
|
|
function attr(name) {
|
|
return (scriptEl && scriptEl.getAttribute(name)) || '';
|
|
}
|
|
|
|
function parseShortcut(s) {
|
|
var parts = String(s).toLowerCase().split('+');
|
|
var sc = { key: parts.pop(), alt: false, shift: false, ctrl: false, meta: false };
|
|
parts.forEach(function (p) {
|
|
if (p === 'alt' || p === 'option') sc.alt = true;
|
|
else if (p === 'shift') sc.shift = true;
|
|
else if (p === 'ctrl' || p === 'control') sc.ctrl = true;
|
|
else if (p === 'meta' || p === 'cmd') sc.meta = true;
|
|
});
|
|
return sc;
|
|
}
|
|
|
|
function init(config) {
|
|
config = config || {};
|
|
if (global.OtelTodo && global.OtelTodo.instance) return global.OtelTodo.instance;
|
|
|
|
var baseUrl = (config.baseUrl || attr('data-base-url') || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
var endpoint = config.endpoint || attr('data-todo-endpoint') || baseUrl + '/v1/todos';
|
|
var project = config.serviceName || attr('data-service-name') || attr('data-service') || 'frontend';
|
|
var ingestKey = config.ingestKey || attr('data-ingest-key');
|
|
var shortcut = parseShortcut(config.shortcut || attr('data-shortcut') || 'alt+shift+t');
|
|
var hideButton = config.hideButton || attr('data-hide-button') === 'true';
|
|
var staticComponents = config.components || attr('data-components').split(',');
|
|
staticComponents = staticComponents.map(function (c) { return c.trim(); }).filter(Boolean);
|
|
|
|
var sessionId = '';
|
|
try { sessionId = global.sessionStorage.getItem('otel-rum-session') || ''; } catch (e) {}
|
|
|
|
var css = document.createElement('style');
|
|
css.textContent =
|
|
'.otel-todo-btn{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:32px;height:32px;border-radius:50%;' +
|
|
'border:1px solid #8886;background:#fffd;color:#333;font:16px/1 system-ui,sans-serif;cursor:pointer;opacity:.55;padding:0}' +
|
|
'.otel-todo-btn:hover,.otel-todo-btn:focus-visible{opacity:1}' +
|
|
'dialog.otel-todo{position:fixed;inset:auto 16px 60px auto;margin:0;width:min(340px,calc(100vw - 32px));padding:12px;' +
|
|
'border:1px solid #8886;border-radius:8px;background:Canvas;color:CanvasText;font:13px system-ui,sans-serif}' +
|
|
'dialog.otel-todo *{box-sizing:border-box;font:inherit}' +
|
|
'dialog.otel-todo textarea,dialog.otel-todo select{display:block;width:100%;margin:6px 0;padding:6px}' +
|
|
'dialog.otel-todo textarea{min-height:90px;resize:vertical}' +
|
|
'dialog.otel-todo .row{display:flex;gap:8px;justify-content:flex-end;align-items:center}' +
|
|
'dialog.otel-todo .meta{opacity:.65;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
'dialog.otel-todo .status{margin-right:auto;font-size:12px}';
|
|
document.head.appendChild(css);
|
|
|
|
var dlg = document.createElement('dialog');
|
|
dlg.className = 'otel-todo';
|
|
dlg.setAttribute('aria-label', 'Add todo');
|
|
dlg.innerHTML =
|
|
'<form method="dialog">' +
|
|
'<div class="meta"></div>' +
|
|
'<textarea name="text" placeholder="What needs doing?" required maxlength="4000"></textarea>' +
|
|
'<select name="component" aria-label="Component (optional)"></select>' +
|
|
'<div class="row"><span class="status" role="status"></span>' +
|
|
'<button type="button" data-cancel>Cancel</button><button type="submit">Add</button></div>' +
|
|
'</form>';
|
|
var form = dlg.firstChild;
|
|
var textEl = form.elements.text;
|
|
var selectEl = form.elements.component;
|
|
var statusEl = form.querySelector('.status');
|
|
var metaEl = form.querySelector('.meta');
|
|
document.body.appendChild(dlg);
|
|
|
|
function components() {
|
|
var seen = {};
|
|
var list = [];
|
|
staticComponents.concat([].map.call(document.querySelectorAll('[data-component]'), function (el) {
|
|
return el.getAttribute('data-component');
|
|
})).forEach(function (c) {
|
|
if (c && !seen[c]) { seen[c] = true; list.push(c); }
|
|
});
|
|
return list;
|
|
}
|
|
|
|
function open() {
|
|
if (dlg.open) { textEl.focus(); return; }
|
|
selectEl.innerHTML = '';
|
|
var none = document.createElement('option');
|
|
none.value = '';
|
|
none.textContent = 'No component';
|
|
selectEl.appendChild(none);
|
|
var list = components();
|
|
list.forEach(function (c) {
|
|
var o = document.createElement('option');
|
|
o.value = o.textContent = c;
|
|
selectEl.appendChild(o);
|
|
});
|
|
selectEl.hidden = list.length === 0;
|
|
metaEl.textContent = project + ' · ' + location.pathname + location.hash;
|
|
statusEl.textContent = '';
|
|
// Non-modal so the page stays usable while the note is being written.
|
|
dlg.show();
|
|
textEl.focus();
|
|
}
|
|
|
|
function close() { if (dlg.open) dlg.close(); }
|
|
|
|
form.querySelector('[data-cancel]').addEventListener('click', close);
|
|
dlg.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
|
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); }
|
|
});
|
|
form.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
var text = textEl.value.trim();
|
|
if (!text) return;
|
|
var headers = { 'Content-Type': 'application/json' };
|
|
var url = endpoint;
|
|
if (ingestKey) headers['X-Otel-Ingest-Key'] = ingestKey;
|
|
statusEl.textContent = 'Sending…';
|
|
global.fetch(url, {
|
|
method: 'POST',
|
|
mode: 'cors',
|
|
headers: headers,
|
|
body: JSON.stringify({
|
|
project: project,
|
|
text: text,
|
|
component: selectEl.value,
|
|
path: location.pathname,
|
|
hash: location.hash,
|
|
url: location.href,
|
|
ua: navigator.userAgent,
|
|
session: sessionId
|
|
})
|
|
}).then(function (res) {
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
textEl.value = '';
|
|
close();
|
|
}).catch(function (err) {
|
|
statusEl.textContent = 'Failed: ' + err.message;
|
|
});
|
|
});
|
|
|
|
global.addEventListener('keydown', function (e) {
|
|
var k = e.key.toLowerCase();
|
|
var code = (e.code || '').replace(/^(Key|Digit)/, '').toLowerCase();
|
|
if ((k === shortcut.key || code === shortcut.key) && e.altKey === shortcut.alt && e.shiftKey === shortcut.shift &&
|
|
e.ctrlKey === shortcut.ctrl && e.metaKey === shortcut.meta) {
|
|
e.preventDefault();
|
|
dlg.open ? close() : open();
|
|
}
|
|
});
|
|
|
|
if (!hideButton) {
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'otel-todo-btn';
|
|
btn.textContent = '✎';
|
|
btn.title = 'Add todo (' + (config.shortcut || attr('data-shortcut') || 'alt+shift+t') + ')';
|
|
btn.setAttribute('aria-label', 'Add todo');
|
|
btn.addEventListener('click', function () { dlg.open ? close() : open(); });
|
|
document.body.appendChild(btn);
|
|
}
|
|
|
|
var instance = { open: open, close: close };
|
|
global.OtelTodo.instance = instance;
|
|
return instance;
|
|
}
|
|
|
|
global.OtelTodo = { init: init, instance: undefined };
|
|
|
|
if (scriptEl && (attr('data-auto-init') === 'true' || attr('data-service-name') || attr('data-service'))) {
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { init(); });
|
|
else init();
|
|
}
|
|
})(window);
|