diff --git a/server.js b/server.js index 0e67166..a7fdbee 100644 --- a/server.js +++ b/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 = \` - -

💬 Submit Feedback

- - - -
+ var overlay = document.createElement('div'); + overlay.id = '_fb-overlay'; + overlay.innerHTML = \` +
+
+

💬 Feedback

+ +
+
+ + +
+
+
+
+ + + +
+
+
+ +
+
\`; - 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 = '
Loading…
'; + fetch(SELF + '/api/issues?repo=' + encodeURIComponent(cfg.repo)) + .then(function(r) { return r.json(); }) + .then(function(issues) { + if (!issues.length) { list.innerHTML = '
No open issues 🎉
'; 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 = \` +

#\${issue.number} \${esc(issue.title)}

+

\${issue.comments} comment\${issue.comments!==1?'s':''} · view on Gitea ↗

+ \${preview ? '

' + esc(preview) + '

' : ''} +
+ + +
+ \`; + 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 = '
Failed to load issues.
'; }); + } + + function esc(s) { return s.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}`));