Redesign widget: large overlay with open issues + comment tab
All checks were successful
Deploy to dev.bl.pixeldev.eu / deploy (push) Successful in 2s
All checks were successful
Deploy to dev.bl.pixeldev.eu / deploy (push) Successful in 2s
This commit is contained in:
parent
0a6255731c
commit
39cb25112b
314
server.js
314
server.js
@ -1,7 +1,6 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@ -12,7 +11,6 @@ const FT_TOKEN = process.env.FT_TOKEN || fs.readFileSync('/root/feedback-tool.en
|
||||
const GITEA_HOST = '127.0.0.1';
|
||||
const GITEA_PORT = 3040;
|
||||
|
||||
// CORS for widget
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
@ -21,46 +19,124 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Serve widget.js — public, no auth
|
||||
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 — list open issues
|
||||
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 FEEDBACK_URL = '${SELF}/api/feedback';
|
||||
|
||||
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'),
|
||||
label: s && s.getAttribute('data-label') || 'Feedback'
|
||||
};
|
||||
return { repo: s && s.getAttribute('data-repo') };
|
||||
})();
|
||||
|
||||
var style = document.createElement('style');
|
||||
style.textContent = \`
|
||||
#_fb-fab { position:fixed; bottom:1.5rem; right:1.5rem; width:52px; height:52px; border-radius:50%;
|
||||
background:#7c9ef5; color:#fff; font-size:22px; border:none; cursor:pointer; box-shadow:0 4px 16px rgba(0,0,0,.4);
|
||||
z-index:99998; display:flex; align-items:center; justify-content:center; transition:transform .15s; }
|
||||
#_fb-fab:hover { transform:scale(1.1); }
|
||||
#_fb-panel { position:fixed; bottom:5.5rem; right:1.5rem; width:320px; background:#1a1a1a;
|
||||
border:1px solid #333; border-radius:14px; padding:1.2rem; z-index:99999; box-shadow:0 8px 32px rgba(0,0,0,.5);
|
||||
font-family:'Segoe UI',system-ui,sans-serif; display:none; flex-direction:column; gap:.75rem; }
|
||||
#_fb-panel.open { display:flex; }
|
||||
#_fb-panel h3 { color:#fff; font-size:.95rem; margin:0; }
|
||||
#_fb-panel input, #_fb-panel textarea { background:#111; border:1px solid #333; border-radius:6px;
|
||||
padding:.5rem .7rem; color:#e0e0e0; font-size:.85rem; width:100%; font-family:inherit; outline:none; resize:vertical; }
|
||||
#_fb-panel input:focus, #_fb-panel textarea:focus { border-color:#7c9ef5; }
|
||||
#_fb-panel textarea { min-height:80px; }
|
||||
#_fb-submit { background:#7c9ef5; color:#fff; border:none; border-radius:6px; padding:.55rem 1rem;
|
||||
font-size:.85rem; font-weight:600; cursor:pointer; width:100%; transition:opacity .15s; }
|
||||
#_fb-submit:hover { opacity:.85; }
|
||||
#_fb-submit:disabled { opacity:.5; cursor:default; }
|
||||
#_fb-status { font-size:.78rem; text-align:center; min-height:1rem; }
|
||||
#_fb-close { position:absolute; top:.75rem; right:.9rem; background:none; border:none; color:#555;
|
||||
cursor:pointer; font-size:1rem; line-height:1; }
|
||||
#_fb-close:hover { color:#aaa; }
|
||||
#_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; }
|
||||
\`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
@ -68,92 +144,126 @@ app.get(['/widget.js', BASE + '/widget.js'], (req, res) => {
|
||||
fab.id = '_fb-fab'; fab.innerHTML = '💬'; fab.title = 'Feedback';
|
||||
document.body.appendChild(fab);
|
||||
|
||||
var panel = document.createElement('div');
|
||||
panel.id = '_fb-panel'; panel.style.position = 'fixed';
|
||||
panel.innerHTML = \`
|
||||
<button id="_fb-close">✕</button>
|
||||
<h3>💬 Submit Feedback</h3>
|
||||
<input id="_fb-title" type="text" placeholder="Title (required)" />
|
||||
<textarea id="_fb-body" placeholder="Describe your feedback..."></textarea>
|
||||
<button id="_fb-submit">Send</button>
|
||||
<div id="_fb-status"></div>
|
||||
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>
|
||||
<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(panel);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
fab.addEventListener('click', function() { panel.classList.toggle('open'); });
|
||||
document.getElementById('_fb-close').addEventListener('click', function() { panel.classList.remove('open'); });
|
||||
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);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('_fb-submit').addEventListener('click', function() {
|
||||
var title = document.getElementById('_fb-title').value.trim();
|
||||
var body = document.getElementById('_fb-body').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(FEEDBACK_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: title, body: body, repo: cfg.repo, source_url: window.location.href })
|
||||
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.issue_url) {
|
||||
if (d.ok) {
|
||||
status.style.color='#6fcf6f';
|
||||
status.textContent='✓ Thanks! Issue #' + d.issue_number + ' created.';
|
||||
status.textContent='✓ Issue #' + d.issue_number + ' created!';
|
||||
document.getElementById('_fb-title').value = '';
|
||||
document.getElementById('_fb-body').value = '';
|
||||
setTimeout(function() { panel.classList.remove('open'); status.textContent=''; btn.disabled=false; }, 2500);
|
||||
document.getElementById('_fb-desc').value = '';
|
||||
btn.disabled = false;
|
||||
setTimeout(function() { status.textContent=''; }, 3000);
|
||||
} else {
|
||||
status.style.color='#f57c7c'; status.textContent='Error: ' + (d.error||'unknown'); btn.disabled=false;
|
||||
}
|
||||
}).catch(function(e) {
|
||||
status.style.color='#f57c7c'; status.textContent='Network error.'; btn.disabled=false;
|
||||
});
|
||||
}).catch(function() { status.style.color='#f57c7c'; status.textContent='Network error.'; btn.disabled=false; });
|
||||
});
|
||||
})();
|
||||
`);
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
})();`);
|
||||
});
|
||||
|
||||
// POST /api/feedback — create Gitea issue
|
||||
app.post(['/api/feedback', BASE + '/api/feedback'], (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');
|
||||
|
||||
const postData = JSON.stringify({ title, body: issueBody });
|
||||
const options = {
|
||||
hostname: GITEA_HOST, port: GITEA_PORT,
|
||||
path: `/api/v1/repos/${repo}/issues`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `token ${FT_TOKEN}`,
|
||||
'Content-Length': Buffer.byteLength(postData)
|
||||
}
|
||||
};
|
||||
|
||||
const request = require('http').request(options, r => {
|
||||
let data = '';
|
||||
r.on('data', c => data += c);
|
||||
r.on('end', () => {
|
||||
try {
|
||||
const issue = JSON.parse(data);
|
||||
if (issue.number) res.json({ ok: true, issue_number: issue.number, issue_url: issue.html_url });
|
||||
else res.status(500).json({ error: 'Failed to create issue', raw: data.slice(0,200) });
|
||||
} catch { res.status(500).json({ error: 'Parse error' }); }
|
||||
});
|
||||
});
|
||||
request.on('error', e => res.status(500).json({ error: e.message }));
|
||||
request.write(postData);
|
||||
request.end();
|
||||
});
|
||||
|
||||
// Redirect base to registry
|
||||
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}`));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user