feedback-tool/server.js
OpenClaw 838e8dbcb7
All checks were successful
Deploy to dev.bl.pixeldev.eu / deploy (push) Successful in 2s
Add picker mode — click any element to capture as code snippet in issue
2026-03-13 13:17:09 +00:00

421 lines
21 KiB
JavaScript

const express = require('express');
const fs = require('fs');
const http = require('http');
const app = express();
// Capture raw body for webhook signature verification before JSON parsing
app.use((req, res, next) => {
if (req.path === '/webhook/gitea') {
let raw = '';
req.on('data', chunk => { raw += chunk; });
req.on('end', () => {
req.rawBody = raw;
try { req.body = JSON.parse(raw); } catch { req.body = {}; }
next();
});
} else {
express.json()(req, res, next);
}
});
const PORT = parseInt(process.env.PORT || '3060');
const BASE = (process.env.BASE_PATH || '/feedback-tool').replace(/\/$/, '');
const FT_TOKEN = process.env.FT_TOKEN || fs.readFileSync('/root/feedback-tool.env','utf8').match(/FT_TOKEN=(.*)/)?.[1]?.trim();
const GITEA_HOST = '127.0.0.1';
const GITEA_PORT = 3040;
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});
function giteaRequest(method, path, body) {
return new Promise((resolve, reject) => {
const postData = body ? JSON.stringify(body) : null;
const options = {
hostname: GITEA_HOST, port: GITEA_PORT, path, method,
headers: {
'Content-Type': 'application/json',
'Authorization': `token ${FT_TOKEN}`,
...(postData ? { 'Content-Length': Buffer.byteLength(postData) } : {})
}
};
const req = http.request(options, r => {
let data = '';
r.on('data', c => data += c);
r.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
});
req.on('error', reject);
if (postData) req.write(postData);
req.end();
});
}
// GET /api/issues?repo=owner/repo
app.get(['/api/issues', BASE + '/api/issues'], async (req, res) => {
const { repo } = req.query;
if (!repo) return res.status(400).json({ error: 'repo required' });
try {
const issues = await giteaRequest('GET', `/api/v1/repos/${repo}/issues?state=open&type=issues&limit=20`);
res.json(Array.isArray(issues) ? issues.map(i => ({
number: i.number, title: i.title,
body: (i.body || '').slice(0, 300),
created_at: i.created_at,
comments: i.comments,
url: i.html_url
})) : []);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/feedback — create new issue
app.post(['/api/feedback', BASE + '/api/feedback'], async (req, res) => {
const { title, body, repo, source_url } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
if (!repo) return res.status(400).json({ error: 'repo required' });
const issueBody = [body || '', '', '---', `*Submitted via feedback widget from: ${source_url || 'unknown'}*`].join('\n');
try {
const issue = await giteaRequest('POST', `/api/v1/repos/${repo}/issues`, { title, body: issueBody });
if (issue.number) res.json({ ok: true, issue_number: issue.number, issue_url: issue.html_url });
else res.status(500).json({ error: 'Failed', raw: JSON.stringify(issue).slice(0, 200) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/comment — add comment to existing issue
app.post(['/api/comment', BASE + '/api/comment'], async (req, res) => {
const { repo, issue_number, body } = req.body;
if (!repo || !issue_number || !body) return res.status(400).json({ error: 'repo, issue_number, body required' });
try {
const comment = await giteaRequest('POST', `/api/v1/repos/${repo}/issues/${issue_number}/comments`, { body });
if (comment.id) res.json({ ok: true, comment_id: comment.id });
else res.status(500).json({ error: 'Failed', raw: JSON.stringify(comment).slice(0, 200) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Serve widget.js
app.get(['/widget.js', BASE + '/widget.js'], (req, res) => {
const SELF = `https://dev.bl.pixeldev.eu${BASE}`;
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('Cache-Control', 'no-cache');
res.send(`(function() {
var SELF = '${SELF}';
var cfg = (function() {
var s = document.currentScript || document.querySelector('script[data-repo]');
return { repo: s && s.getAttribute('data-repo') };
})();
// ── Styles ────────────────────────────────────────────────────────────────
var style = document.createElement('style');
style.textContent = \`
#_fb-fab { position:fixed; bottom:1.5rem; right:1.5rem; width:54px; height:54px; border-radius:50%;
background:#7c9ef5; color:#fff; font-size:22px; border:none; cursor:pointer;
box-shadow:0 4px 20px rgba(124,158,245,.4); z-index:99998;
display:flex; align-items:center; justify-content:center; transition:transform .15s,box-shadow .15s; }
#_fb-fab:hover { transform:scale(1.08); box-shadow:0 6px 24px rgba(124,158,245,.6); }
#_fb-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6); backdrop-filter:blur(4px);
z-index:99998; display:none; align-items:center; justify-content:center; }
#_fb-overlay.open { display:flex; }
#_fb-modal { background:#161616; border:1px solid #2a2a2a; border-radius:16px; width:min(560px,96vw);
max-height:80vh; display:flex; flex-direction:column; box-shadow:0 24px 64px rgba(0,0,0,.7);
font-family:'Segoe UI',system-ui,sans-serif; overflow:hidden; }
#_fb-header { display:flex; align-items:center; justify-content:space-between; padding:1.1rem 1.4rem;
border-bottom:1px solid #222; flex-shrink:0; }
#_fb-header h2 { color:#fff; font-size:1rem; margin:0; font-weight:600; }
#_fb-hclose { background:none; border:none; color:#555; cursor:pointer; font-size:1.2rem; line-height:1; padding:0; }
#_fb-hclose:hover { color:#aaa; }
#_fb-tabs { display:flex; border-bottom:1px solid #222; flex-shrink:0; }
.fb-tab { flex:1; padding:.7rem; background:none; border:none; color:#666; font-size:.85rem;
cursor:pointer; font-family:inherit; border-bottom:2px solid transparent; transition:color .15s; }
.fb-tab.active { color:#7c9ef5; border-bottom-color:#7c9ef5; }
.fb-tab:hover { color:#aaa; }
#_fb-body { padding:1.2rem 1.4rem; overflow-y:auto; display:flex; flex-direction:column; gap:.8rem; }
.fb-input, .fb-textarea { background:#0d0d0d; border:1px solid #2a2a2a; border-radius:8px;
padding:.6rem .8rem; color:#e0e0e0; font-size:.875rem; width:100%; font-family:inherit;
outline:none; resize:vertical; transition:border-color .15s; }
.fb-input:focus, .fb-textarea:focus { border-color:#7c9ef5; }
.fb-textarea { min-height:90px; }
.fb-btn { background:#7c9ef5; color:#fff; border:none; border-radius:8px; padding:.65rem 1.2rem;
font-size:.875rem; font-weight:600; cursor:pointer; transition:opacity .15s; width:100%; }
.fb-btn:hover { opacity:.85; }
.fb-btn:disabled { opacity:.4; cursor:default; }
.fb-status { font-size:.8rem; text-align:center; min-height:1.1rem; }
.fb-issue-item { background:#0d0d0d; border:1px solid #222; border-radius:10px; padding:.9rem 1rem; }
.fb-issue-title { color:#e0e0e0; font-size:.9rem; font-weight:600; margin:0 0 .3rem; }
.fb-issue-meta { color:#555; font-size:.75rem; margin-bottom:.6rem; }
.fb-issue-body { color:#888; font-size:.8rem; margin-bottom:.7rem; white-space:pre-wrap; word-break:break-word; }
.fb-comment-area { display:flex; flex-direction:column; gap:.5rem; }
.fb-comment-input { min-height:55px; }
.fb-comment-btn { background:#2a2a2a; color:#ccc; font-size:.8rem; padding:.45rem .8rem; width:auto; align-self:flex-end; border-radius:6px; }
.fb-comment-btn:hover { background:#333; opacity:1; }
.fb-empty { color:#555; font-size:.85rem; text-align:center; padding:1.5rem 0; }
.fb-loading { color:#555; font-size:.85rem; text-align:center; padding:1rem 0; }
/* Picker row inside new-issue form */
.fb-picker-row { display:flex; align-items:center; gap:.6rem; }
.fb-picker-btn { background:#1a2a1a; border:1px solid #2a4a2a; color:#6fcf6f; font-size:.78rem;
padding:.4rem .7rem; border-radius:6px; cursor:pointer; white-space:nowrap; flex-shrink:0; }
.fb-picker-btn:hover { background:#223322; }
.fb-picker-hint { color:#444; font-size:.75rem; }
.fb-snippet-preview { background:#0a0a0a; border:1px solid #2a2a2a; border-radius:6px;
padding:.5rem .7rem; font-size:.72rem; color:#7c9ef5; font-family:monospace;
white-space:pre-wrap; word-break:break-all; max-height:80px; overflow-y:auto; }
/* Picker overlay */
#_fb-picker-shield { display:none; position:fixed; inset:0; z-index:199999; cursor:crosshair; }
#_fb-picker-shield.active { display:block; }
#_fb-picker-toast { position:fixed; top:1rem; left:50%; transform:translateX(-50%);
background:#1a1a1a; border:1px solid #7c9ef5; border-radius:8px;
padding:.6rem 1.1rem; color:#e0e0e0; font-size:.85rem; z-index:200000;
font-family:'Segoe UI',system-ui,sans-serif; display:none; box-shadow:0 4px 16px rgba(0,0,0,.5); }
#_fb-picker-toast.active { display:block; }
._fb-hover-highlight { outline:2px solid #7c9ef5 !important; outline-offset:2px !important; }
\`;
document.head.appendChild(style);
// ── FAB ───────────────────────────────────────────────────────────────────
var fab = document.createElement('button');
fab.id = '_fb-fab'; fab.innerHTML = '💬'; fab.title = 'Feedback';
document.body.appendChild(fab);
// ── Picker shield (invisible overlay during pick mode) ────────────────────
var shield = document.createElement('div');
shield.id = '_fb-picker-shield';
document.body.appendChild(shield);
var toast = document.createElement('div');
toast.id = '_fb-picker-toast';
toast.textContent = '🎯 Click any element to capture it — Esc to cancel';
document.body.appendChild(toast);
// ── Main overlay ──────────────────────────────────────────────────────────
var overlay = document.createElement('div');
overlay.id = '_fb-overlay';
overlay.innerHTML = \`
<div id="_fb-modal">
<div id="_fb-header">
<h2>💬 Feedback</h2>
<button id="_fb-hclose">✕</button>
</div>
<div id="_fb-tabs">
<button class="fb-tab active" data-tab="new">New Issue</button>
<button class="fb-tab" data-tab="open">Open Issues</button>
</div>
<div id="_fb-body">
<div id="_fb-tab-new">
<div style="display:flex;flex-direction:column;gap:.8rem;">
<input class="fb-input" id="_fb-title" type="text" placeholder="Title (required)" />
<textarea class="fb-textarea" id="_fb-desc" placeholder="Describe your feedback…"></textarea>
<div class="fb-picker-row">
<button class="fb-picker-btn" id="_fb-pick-btn">🎯 Pick element</button>
<span class="fb-picker-hint" id="_fb-pick-hint">Click to reference a page element</span>
</div>
<div id="_fb-snippet-wrap" style="display:none;">
<div class="fb-snippet-preview" id="_fb-snippet"></div>
</div>
<button class="fb-btn" id="_fb-submit">Submit Issue</button>
<div class="fb-status" id="_fb-status"></div>
</div>
</div>
<div id="_fb-tab-open" style="display:none;">
<div id="_fb-issues-list"><div class="fb-loading">Loading issues…</div></div>
</div>
</div>
</div>
\`;
document.body.appendChild(overlay);
// ── Open / close ──────────────────────────────────────────────────────────
function openOverlay() { overlay.classList.add('open'); }
function closeOverlay() { overlay.classList.remove('open'); }
fab.addEventListener('click', function() {
openOverlay();
if (currentTab === 'open') loadIssues();
});
overlay.addEventListener('click', function(e) { if (e.target === overlay) closeOverlay(); });
document.getElementById('_fb-hclose').addEventListener('click', closeOverlay);
// ── Tabs ──────────────────────────────────────────────────────────────────
var currentTab = 'new';
document.querySelectorAll('.fb-tab').forEach(function(btn) {
btn.addEventListener('click', function() {
currentTab = btn.dataset.tab;
document.querySelectorAll('.fb-tab').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
document.getElementById('_fb-tab-new').style.display = currentTab === 'new' ? '' : 'none';
document.getElementById('_fb-tab-open').style.display = currentTab === 'open' ? '' : 'none';
if (currentTab === 'open') loadIssues();
});
});
// ── Picker mode ───────────────────────────────────────────────────────────
var pickerActive = false;
var pickerHighlighted = null;
var capturedSnippet = '';
function getSelector(el) {
if (!el || el === document.body) return 'body';
var tag = el.tagName.toLowerCase();
var id = el.id ? '#' + el.id : '';
var cls = el.className && typeof el.className === 'string'
? '.' + el.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
return tag + id + cls;
}
function getSnippet(el) {
// Outer HTML truncated sensibly
var html = el.outerHTML || '';
if (html.length > 500) {
// Just show tag + attrs + content preview
var inner = (el.textContent || '').trim().slice(0, 120);
var m = html.match(/^(<[^>]+>)/);
html = (m ? m[1] : '<' + el.tagName.toLowerCase() + '>') + (inner ? '\\n ' + inner + '…' : '') + '\\n</' + el.tagName.toLowerCase() + '>';
}
return html;
}
function activatePicker() {
pickerActive = true;
overlay.classList.remove('open');
shield.classList.add('active');
toast.classList.add('active');
}
function deactivatePicker(snippet) {
pickerActive = false;
shield.classList.remove('active');
toast.classList.remove('active');
if (pickerHighlighted) {
pickerHighlighted.classList.remove('_fb-hover-highlight');
pickerHighlighted = null;
}
overlay.classList.add('open');
if (snippet) {
capturedSnippet = snippet;
document.getElementById('_fb-snippet').textContent = snippet;
document.getElementById('_fb-snippet-wrap').style.display = '';
document.getElementById('_fb-pick-hint').textContent = '✓ Element captured — you can pick again';
// Append to description
var desc = document.getElementById('_fb-desc');
var tag = '\\n\\n**Picked element:**\\n\`\`\`html\\n' + snippet + '\\n\`\`\`';
if (!desc.value.includes(tag)) desc.value += tag;
}
}
document.getElementById('_fb-pick-btn').addEventListener('click', function() {
activatePicker();
});
// Mousemove on page during picker: highlight hovered element
document.addEventListener('mousemove', function(e) {
if (!pickerActive) return;
var el = document.elementFromPoint(e.clientX, e.clientY);
// Skip our own UI elements
if (!el || el.closest('#_fb-picker-shield') || el.closest('#_fb-picker-toast')) return;
if (pickerHighlighted && pickerHighlighted !== el) {
pickerHighlighted.classList.remove('_fb-hover-highlight');
}
pickerHighlighted = el;
el.classList.add('_fb-hover-highlight');
});
// Click during picker: capture element
shield.addEventListener('click', function(e) {
if (!pickerActive) return;
e.preventDefault(); e.stopPropagation();
// Find element underneath the shield
shield.style.display = 'none';
var el = document.elementFromPoint(e.clientX, e.clientY);
shield.style.display = '';
if (el && !el.closest('#_fb-picker-toast')) {
el.classList.remove('_fb-hover-highlight');
deactivatePicker(getSnippet(el));
} else {
deactivatePicker(null);
}
});
// Esc cancels picker
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && pickerActive) deactivatePicker(null);
});
// ── Submit ────────────────────────────────────────────────────────────────
document.getElementById('_fb-submit').addEventListener('click', function() {
var title = document.getElementById('_fb-title').value.trim();
var body = document.getElementById('_fb-desc').value.trim();
var status = document.getElementById('_fb-status');
var btn = document.getElementById('_fb-submit');
if (!title) { status.style.color='#f57c7c'; status.textContent='Title is required.'; return; }
btn.disabled = true; status.style.color='#888'; status.textContent='Submitting…';
fetch(SELF + '/api/feedback', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, body, repo: cfg.repo, source_url: window.location.href })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.ok) {
status.style.color='#6fcf6f';
status.textContent='✓ Issue #' + d.issue_number + ' created!';
document.getElementById('_fb-title').value = '';
document.getElementById('_fb-desc').value = '';
document.getElementById('_fb-snippet-wrap').style.display = 'none';
document.getElementById('_fb-pick-hint').textContent = 'Click to reference a page element';
capturedSnippet = '';
btn.disabled = false;
setTimeout(function() { status.textContent=''; }, 3000);
} else {
status.style.color='#f57c7c'; status.textContent='Error: ' + (d.error||'unknown'); btn.disabled=false;
}
}).catch(function() { status.style.color='#f57c7c'; status.textContent='Network error.'; btn.disabled=false; });
});
// ── Open issues tab ───────────────────────────────────────────────────────
function loadIssues() {
var list = document.getElementById('_fb-issues-list');
list.innerHTML = '<div class="fb-loading">Loading…</div>';
fetch(SELF + '/api/issues?repo=' + encodeURIComponent(cfg.repo))
.then(function(r) { return r.json(); })
.then(function(issues) {
if (!issues.length) { list.innerHTML = '<div class="fb-empty">No open issues 🎉</div>'; return; }
list.innerHTML = '';
issues.forEach(function(issue) {
var el = document.createElement('div');
el.className = 'fb-issue-item';
var preview = (issue.body || '').replace(/---[\\s\\S]*$/, '').trim().slice(0, 150);
el.innerHTML = \`
<p class="fb-issue-title">#\${issue.number} \${esc(issue.title)}</p>
<p class="fb-issue-meta">\${issue.comments} comment\${issue.comments!==1?'s':''} · <a href="\${issue.url}" target="_blank" style="color:#7c9ef5;text-decoration:none;">view on Gitea ↗</a></p>
\${preview ? '<p class="fb-issue-body">' + esc(preview) + '</p>' : ''}
<div class="fb-comment-area">
<textarea class="fb-input fb-textarea fb-comment-input" placeholder="Add a comment…" data-issue="\${issue.number}"></textarea>
<button class="fb-btn fb-comment-btn" data-issue="\${issue.number}">Comment</button>
</div>
\`;
list.appendChild(el);
el.querySelector('.fb-comment-btn').addEventListener('click', function() {
var num = this.dataset.issue;
var ta = el.querySelector('textarea[data-issue="'+num+'"]');
var body = ta.value.trim();
if (!body) return;
var btn = this;
btn.disabled = true; btn.textContent = 'Posting…';
fetch(SELF + '/api/comment', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: cfg.repo, issue_number: num, body })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.ok) { ta.value=''; btn.textContent='✓ Posted!'; setTimeout(function(){ btn.textContent='Comment'; btn.disabled=false; }, 2000); }
else { btn.textContent='Error'; btn.disabled=false; }
}).catch(function() { btn.textContent='Error'; btn.disabled=false; });
});
});
}).catch(function() { list.innerHTML = '<div class="fb-empty">Failed to load issues.</div>'; });
}
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
})();`);
});
app.get([BASE, BASE + '/'], (req, res) => res.redirect('https://dev.bl.pixeldev.eu'));
app.listen(PORT, '127.0.0.1', () => console.log(`feedback-tool on :${PORT} at ${BASE}`));