Add screenshot uploads to feedback modal, Webex bot DM on issue close
- Feedback modal now supports up to 3 image attachments (PNG/JPG/GIF/WebP, 5MB each) with thumbnail previews. Images are uploaded to GitLab project uploads and embedded as markdown in the issue description. - New webhook endpoint (POST /api/webhooks/gitlab) receives issue close events, parses the submitter from the description, looks up their email, and sends a Webex DM via the Patches O'Houlihan bot. - New helper: backend/helpers/webexBot.js (fire-and-forget DM sender). - Requires WEBEX_BOT_TOKEN and GITLAB_WEBHOOK_SECRET in backend/.env.
This commit is contained in:
@@ -74,3 +74,12 @@ DATABASE_URL=postgresql://steam:<password>@localhost:5433/cve_dashboard
|
|||||||
GITLAB_URL=http://steam-gitlab.charterlab.com
|
GITLAB_URL=http://steam-gitlab.charterlab.com
|
||||||
GITLAB_PROJECT_ID=
|
GITLAB_PROJECT_ID=
|
||||||
GITLAB_PAT=
|
GITLAB_PAT=
|
||||||
|
|
||||||
|
# GitLab Webhook Secret — shared secret for validating incoming webhook requests.
|
||||||
|
# Set this same value in GitLab project > Settings > Webhooks > Secret Token.
|
||||||
|
# Generate with: openssl rand -hex 20
|
||||||
|
GITLAB_WEBHOOK_SECRET=changeme_generate_a_random_secret
|
||||||
|
|
||||||
|
# Webex Bot — used to DM users when their feedback issues are resolved.
|
||||||
|
# Create a bot at https://developer.webex.com/my-apps and use its access token.
|
||||||
|
WEBEX_BOT_TOKEN=
|
||||||
|
|||||||
64
backend/helpers/webexBot.js
Normal file
64
backend/helpers/webexBot.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Webex Bot DM Helper
|
||||||
|
// Fire-and-forget pattern — logs success/failure but never throws.
|
||||||
|
// Used to notify users when their feedback issues are resolved.
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const WEBEX_API_URL = 'https://webexapis.com/v1/messages';
|
||||||
|
const WEBEX_BOT_TOKEN = process.env.WEBEX_BOT_TOKEN || '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a direct message to a user via Webex Teams bot.
|
||||||
|
* @param {string} email - Recipient's email address
|
||||||
|
* @param {string} markdownMessage - Message body (Webex markdown supported)
|
||||||
|
*/
|
||||||
|
function sendDirectMessage(email, markdownMessage) {
|
||||||
|
if (!WEBEX_BOT_TOKEN) {
|
||||||
|
console.warn('[WebexBot] WEBEX_BOT_TOKEN not configured — skipping DM');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email || !markdownMessage) {
|
||||||
|
console.warn('[WebexBot] Missing email or message — skipping DM');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const postData = JSON.stringify({
|
||||||
|
toPersonEmail: email,
|
||||||
|
markdown: markdownMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = new URL(WEBEX_API_URL);
|
||||||
|
|
||||||
|
const reqOpts = {
|
||||||
|
method: 'POST',
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
path: parsed.pathname,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${WEBEX_BOT_TOKEN}`,
|
||||||
|
'Content-Length': Buffer.byteLength(postData),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(reqOpts, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => { data += chunk; });
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
console.log(`[WebexBot] DM sent to ${email} (${res.statusCode})`);
|
||||||
|
} else {
|
||||||
|
console.error(`[WebexBot] Failed to DM ${email} — ${res.statusCode}: ${data}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err) => {
|
||||||
|
console.error(`[WebexBot] Request error sending DM to ${email}:`, err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(postData);
|
||||||
|
req.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendDirectMessage };
|
||||||
@@ -1,11 +1,54 @@
|
|||||||
// Feedback route — proxies bug reports and feature requests to GitLab Issues API
|
// Feedback route — proxies bug reports and feature requests to GitLab Issues API
|
||||||
|
// Supports optional screenshot uploads (up to 3 images, 5MB each).
|
||||||
// Keeps the GitLab PAT server-side so it's never exposed to the browser.
|
// Keeps the GitLab PAT server-side so it's never exposed to the browser.
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const multer = require('multer');
|
||||||
const { requireAuth } = require('../middleware/auth');
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Multer setup for screenshot uploads — same temp directory pattern as compliance
|
||||||
|
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
|
||||||
|
|
||||||
|
const screenshotStorage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
if (!fs.existsSync(TEMP_DIR)) {
|
||||||
|
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
cb(null, TEMP_DIR);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const safeName = file.originalname
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.replace(/\.\./g, '')
|
||||||
|
.replace(/[\/\\]/g, '')
|
||||||
|
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||||
|
.trim();
|
||||||
|
cb(null, `${timestamp}-${safeName}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ALLOWED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
|
const screenshotUpload = multer({
|
||||||
|
storage: screenshotStorage,
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!ALLOWED_IMAGE_TYPES.has(file.mimetype)) {
|
||||||
|
return cb(new Error(`File type '${file.mimetype}' not allowed. Only PNG, JPG, GIF, and WebP images are accepted.`));
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_FILE_SIZE,
|
||||||
|
files: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function createFeedbackRouter() {
|
function createFeedbackRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -13,44 +56,154 @@ function createFeedbackRouter() {
|
|||||||
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
|
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
|
||||||
const GITLAB_PAT = process.env.GITLAB_PAT || '';
|
const GITLAB_PAT = process.env.GITLAB_PAT || '';
|
||||||
|
|
||||||
router.post('/', requireAuth(), async (req, res) => {
|
/**
|
||||||
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
|
* Upload a single file to GitLab's project uploads API.
|
||||||
return res.status(503).json({ error: 'Feedback integration not configured' });
|
* Returns { markdown, url } on success, null on failure.
|
||||||
}
|
*/
|
||||||
|
function uploadFileToGitlab(filePath, fileName) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/uploads`;
|
||||||
|
|
||||||
const { type, title, description, page } = req.body;
|
try {
|
||||||
|
const parsed = new URL(apiUrl);
|
||||||
|
const transport = parsed.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
if (!type || !title || !description) {
|
const boundary = `----FormBoundary${Date.now()}${Math.random().toString(36).slice(2)}`;
|
||||||
return res.status(400).json({ error: 'type, title, and description are required' });
|
const fileContent = fs.readFileSync(filePath);
|
||||||
}
|
|
||||||
|
|
||||||
if (!['bug', 'feature'].includes(type)) {
|
const header = Buffer.from(
|
||||||
return res.status(400).json({ error: 'type must be "bug" or "feature"' });
|
`--${boundary}\r\n` +
|
||||||
}
|
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
|
||||||
|
`Content-Type: application/octet-stream\r\n\r\n`
|
||||||
|
);
|
||||||
|
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||||
|
const body = Buffer.concat([header, fileContent, footer]);
|
||||||
|
|
||||||
const labels = type === 'bug' ? 'bug' : 'enhancement';
|
const reqOpts = {
|
||||||
const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request';
|
method: 'POST',
|
||||||
const username = req.user?.username || 'unknown';
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port,
|
||||||
|
path: parsed.pathname + parsed.search,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
'PRIVATE-TOKEN': GITLAB_PAT,
|
||||||
|
'Content-Length': body.length,
|
||||||
|
},
|
||||||
|
rejectAuthorized: false,
|
||||||
|
};
|
||||||
|
|
||||||
const body = [
|
const apiReq = transport.request(reqOpts, (apiRes) => {
|
||||||
`**Submitted by:** ${username}`,
|
let data = '';
|
||||||
page ? `**Page:** ${page}` : null,
|
apiRes.on('data', chunk => data += chunk);
|
||||||
`**Type:** ${prefix}`,
|
apiRes.on('end', () => {
|
||||||
'',
|
try {
|
||||||
'---',
|
const result = JSON.parse(data);
|
||||||
'',
|
if (apiRes.statusCode === 201 && result.markdown) {
|
||||||
description,
|
resolve(result);
|
||||||
].filter(Boolean).join('\n');
|
} else {
|
||||||
|
console.error(`[Feedback] GitLab upload returned ${apiRes.statusCode}:`, data);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error('[Feedback] GitLab upload returned invalid JSON:', data);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const postData = JSON.stringify({
|
apiReq.on('error', (err) => {
|
||||||
title: `[${prefix}] ${title}`,
|
console.error('[Feedback] GitLab upload request error:', err.message);
|
||||||
description: body,
|
resolve(null);
|
||||||
labels,
|
});
|
||||||
|
|
||||||
|
apiReq.write(body);
|
||||||
|
apiReq.end();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Feedback] GitLab upload error:', err.message);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`;
|
/**
|
||||||
|
* Clean up temp files after processing.
|
||||||
|
*/
|
||||||
|
function cleanupFiles(files) {
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(file.path)) {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Feedback] Failed to clean up temp file ${file.path}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/', requireAuth(), screenshotUpload.array('screenshots', 3), async (req, res) => {
|
||||||
|
const uploadedFiles = req.files || [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
|
||||||
|
return res.status(503).json({ error: 'Feedback integration not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, title, description, page } = req.body;
|
||||||
|
|
||||||
|
if (!type || !title || !description) {
|
||||||
|
return res.status(400).json({ error: 'type, title, and description are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['bug', 'feature'].includes(type)) {
|
||||||
|
return res.status(400).json({ error: 'type must be "bug" or "feature"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = type === 'bug' ? 'bug' : 'enhancement';
|
||||||
|
const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request';
|
||||||
|
const username = req.user?.username || 'unknown';
|
||||||
|
|
||||||
|
// Upload screenshots to GitLab and collect markdown links
|
||||||
|
const imageMarkdowns = [];
|
||||||
|
for (const file of uploadedFiles) {
|
||||||
|
const result = await uploadFileToGitlab(file.path, file.originalname);
|
||||||
|
if (result && result.markdown) {
|
||||||
|
imageMarkdowns.push(result.markdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyParts = [
|
||||||
|
`**Submitted by:** ${username}`,
|
||||||
|
page ? `**Page:** ${page}` : null,
|
||||||
|
`**Type:** ${prefix}`,
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
description,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
// Append screenshot markdown at the bottom
|
||||||
|
if (imageMarkdowns.length > 0) {
|
||||||
|
bodyParts.push('');
|
||||||
|
bodyParts.push('---');
|
||||||
|
bodyParts.push('');
|
||||||
|
bodyParts.push('**Screenshots:**');
|
||||||
|
bodyParts.push('');
|
||||||
|
for (const md of imageMarkdowns) {
|
||||||
|
bodyParts.push(md);
|
||||||
|
bodyParts.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = bodyParts.join('\n');
|
||||||
|
|
||||||
|
const postData = JSON.stringify({
|
||||||
|
title: `[${prefix}] ${title}`,
|
||||||
|
description: body,
|
||||||
|
labels,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`;
|
||||||
|
|
||||||
const result = await new Promise((resolve, reject) => {
|
const result = await new Promise((resolve, reject) => {
|
||||||
const parsed = new URL(apiUrl);
|
const parsed = new URL(apiUrl);
|
||||||
const transport = parsed.protocol === 'https:' ? https : http;
|
const transport = parsed.protocol === 'https:' ? https : http;
|
||||||
@@ -102,6 +255,9 @@ function createFeedbackRouter() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Feedback] Request failed:', err.message);
|
console.error('[Feedback] Request failed:', err.message);
|
||||||
res.status(502).json({ error: 'Failed to connect to GitLab' });
|
res.status(502).json({ error: 'Failed to connect to GitLab' });
|
||||||
|
} finally {
|
||||||
|
// Always clean up temp files
|
||||||
|
cleanupFiles(uploadedFiles);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
73
backend/routes/webhooks.js
Normal file
73
backend/routes/webhooks.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// GitLab Webhook Routes — receives issue lifecycle events from GitLab
|
||||||
|
// Used to notify users via Webex when their feedback issues are closed.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const pool = require('../db');
|
||||||
|
const { sendDirectMessage } = require('../helpers/webexBot');
|
||||||
|
|
||||||
|
const GITLAB_WEBHOOK_SECRET = process.env.GITLAB_WEBHOOK_SECRET || '';
|
||||||
|
|
||||||
|
function createWebhooksRouter() {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// POST /api/webhooks/gitlab — GitLab issue webhook receiver
|
||||||
|
router.post('/gitlab', express.json(), async (req, res) => {
|
||||||
|
// Always return 200 — webhooks should not retry on app-level failures
|
||||||
|
try {
|
||||||
|
// Validate webhook secret token
|
||||||
|
const token = req.headers['x-gitlab-token'];
|
||||||
|
if (!GITLAB_WEBHOOK_SECRET || token !== GITLAB_WEBHOOK_SECRET) {
|
||||||
|
console.warn('[Webhook] Invalid or missing X-Gitlab-Token');
|
||||||
|
return res.status(200).json({ status: 'ignored', reason: 'invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { object_attributes } = req.body || {};
|
||||||
|
|
||||||
|
// Only process issue close events
|
||||||
|
if (!object_attributes || object_attributes.action !== 'close') {
|
||||||
|
return res.status(200).json({ status: 'ignored', reason: 'not a close event' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueTitle = object_attributes.title || 'Untitled';
|
||||||
|
const issueNumber = object_attributes.iid;
|
||||||
|
const description = object_attributes.description || '';
|
||||||
|
|
||||||
|
// Parse submitter username from issue description
|
||||||
|
// Format: **Submitted by:** username
|
||||||
|
const submitterMatch = description.match(/\*\*Submitted by:\*\*\s*(\S+)/);
|
||||||
|
if (!submitterMatch) {
|
||||||
|
console.log('[Webhook] No submitter found in issue description — skipping DM');
|
||||||
|
return res.status(200).json({ status: 'ignored', reason: 'no submitter in description' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = submitterMatch[1];
|
||||||
|
|
||||||
|
// Look up user email in database
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT email FROM users WHERE username = $1',
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0 || !rows[0].email) {
|
||||||
|
console.log(`[Webhook] No email found for user "${username}" — skipping DM`);
|
||||||
|
return res.status(200).json({ status: 'ignored', reason: 'user email not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = rows[0].email;
|
||||||
|
|
||||||
|
// Send Webex DM notification
|
||||||
|
const message = `Hey! Your bug report **${issueTitle}** (Issue #${issueNumber}) has been resolved and deployed. — Patches O'Houlihan`;
|
||||||
|
sendDirectMessage(email, message);
|
||||||
|
|
||||||
|
console.log(`[Webhook] Issue #${issueNumber} closed — notified ${username} (${email})`);
|
||||||
|
return res.status(200).json({ status: 'ok', notified: username });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Webhook] Error processing GitLab webhook:', err.message);
|
||||||
|
return res.status(200).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createWebhooksRouter;
|
||||||
@@ -33,6 +33,7 @@ const createAtlasRouter = require('./routes/atlas');
|
|||||||
const createJiraTicketsRouter = require('./routes/jiraTickets');
|
const createJiraTicketsRouter = require('./routes/jiraTickets');
|
||||||
const createCardApiRouter = require('./routes/cardApi');
|
const createCardApiRouter = require('./routes/cardApi');
|
||||||
const createFeedbackRouter = require('./routes/feedback');
|
const createFeedbackRouter = require('./routes/feedback');
|
||||||
|
const createWebhooksRouter = require('./routes/webhooks');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -230,6 +231,9 @@ app.use('/api/card', createCardApiRouter());
|
|||||||
// Feedback routes — bug reports and feature requests to GitLab
|
// Feedback routes — bug reports and feature requests to GitLab
|
||||||
app.use('/api/feedback', createFeedbackRouter());
|
app.use('/api/feedback', createFeedbackRouter());
|
||||||
|
|
||||||
|
// GitLab webhook routes — receives issue lifecycle events (no auth required)
|
||||||
|
app.use('/api/webhooks', createWebhooksRouter());
|
||||||
|
|
||||||
// ========== CVE ENDPOINTS ==========
|
// ========== CVE ENDPOINTS ==========
|
||||||
|
|
||||||
// Get all CVEs with optional filters (authenticated users)
|
// Get all CVEs with optional filters (authenticated users)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { X, Bug, Lightbulb, Send, Loader, CheckCircle, AlertCircle } from 'lucide-react';
|
import { X, Bug, Lightbulb, Send, Loader, CheckCircle, AlertCircle, Image, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
// ⚠️ CONVENTION: Use relative API path from REACT_APP_API_BASE only — the hardcoded fallback 'http://localhost:3001/api' is an absolute URL. Other components use just the env var without an absolute fallback.
|
// ⚠️ CONVENTION: Use relative API path from REACT_APP_API_BASE only — the hardcoded fallback 'http://localhost:3001/api' is an absolute URL. Other components use just the env var without an absolute fallback.
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
@@ -124,19 +124,102 @@ function TypeSelector({ value, onChange }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Screenshot thumbnails
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ScreenshotPreviews({ files, onRemove }) {
|
||||||
|
if (files.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
|
||||||
|
{files.map((file, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '64px', height: '64px',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(14,165,233,0.2)',
|
||||||
|
background: 'rgba(14,165,233,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(file)}
|
||||||
|
alt={file.name}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(idx)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '2px', right: '2px',
|
||||||
|
width: '18px', height: '18px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(239,68,68,0.9)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
title="Remove screenshot"
|
||||||
|
>
|
||||||
|
<Trash2 style={{ width: 10, height: 10, color: '#fff' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main modal component
|
// Main modal component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
const MAX_FILES = 3;
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
const ACCEPTED_TYPES = 'image/png,image/jpeg,image/gif,image/webp';
|
||||||
|
|
||||||
export default function FeedbackModal({ isOpen, onClose, defaultType, currentPage }) {
|
export default function FeedbackModal({ isOpen, onClose, defaultType, currentPage }) {
|
||||||
const [type, setType] = useState(defaultType || 'bug');
|
const [type, setType] = useState(defaultType || 'bug');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
const [screenshots, setScreenshots] = useState([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [success, setSuccess] = useState(null);
|
const [success, setSuccess] = useState(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const newFiles = Array.from(e.target.files || []);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Validate file count
|
||||||
|
const totalFiles = screenshots.length + newFiles.length;
|
||||||
|
if (totalFiles > MAX_FILES) {
|
||||||
|
setError(`Maximum ${MAX_FILES} screenshots allowed`);
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file sizes
|
||||||
|
for (const file of newFiles) {
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
setError(`"${file.name}" exceeds 5MB limit`);
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setScreenshots(prev => [...prev, ...newFiles]);
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveScreenshot = (idx) => {
|
||||||
|
setScreenshots(prev => prev.filter((_, i) => i !== idx));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title.trim() || !description.trim()) {
|
if (!title.trim() || !description.trim()) {
|
||||||
@@ -147,16 +230,21 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('type', type);
|
||||||
|
formData.append('title', title.trim());
|
||||||
|
formData.append('description', description.trim());
|
||||||
|
if (currentPage) {
|
||||||
|
formData.append('page', currentPage);
|
||||||
|
}
|
||||||
|
for (const file of screenshots) {
|
||||||
|
formData.append('screenshots', file);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/feedback`, {
|
const res = await fetch(`${API_BASE}/feedback`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: formData,
|
||||||
body: JSON.stringify({
|
|
||||||
type,
|
|
||||||
title: title.trim(),
|
|
||||||
description: description.trim(),
|
|
||||||
page: currentPage || null,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.error || `Submission failed (${res.status})`);
|
if (!res.ok) throw new Error(data.error || `Submission failed (${res.status})`);
|
||||||
@@ -164,6 +252,7 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag
|
|||||||
setSuccess(data.issue);
|
setSuccess(data.issue);
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
|
setScreenshots([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -176,6 +265,7 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag
|
|||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
|
setScreenshots([]);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -293,6 +383,43 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Screenshots */}
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Screenshots (optional, max 3)</label>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_TYPES}
|
||||||
|
multiple
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={screenshots.length >= MAX_FILES}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
background: 'rgba(14,165,233,0.06)',
|
||||||
|
border: '1px solid rgba(14,165,233,0.2)',
|
||||||
|
color: screenshots.length >= MAX_FILES ? '#475569' : '#94A3B8',
|
||||||
|
cursor: screenshots.length >= MAX_FILES ? 'not-allowed' : 'pointer',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image style={{ width: 14, height: 14 }} />
|
||||||
|
{screenshots.length >= MAX_FILES ? 'Max reached' : 'Attach images'}
|
||||||
|
</button>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.65rem', color: '#475569', marginLeft: '0.5rem',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
}}>
|
||||||
|
PNG, JPG, GIF, WebP — 5MB each
|
||||||
|
</span>
|
||||||
|
<ScreenshotPreviews files={screenshots} onRemove={handleRemoveScreenshot} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Current page indicator */}
|
{/* Current page indicator */}
|
||||||
{currentPage && (
|
{currentPage && (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
Reference in New Issue
Block a user