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_PROJECT_ID=
|
||||
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
|
||||
// Supports optional screenshot uploads (up to 3 images, 5MB each).
|
||||
// Keeps the GitLab PAT server-side so it's never exposed to the browser.
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
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() {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -13,7 +56,94 @@ function createFeedbackRouter() {
|
||||
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
|
||||
const GITLAB_PAT = process.env.GITLAB_PAT || '';
|
||||
|
||||
router.post('/', requireAuth(), async (req, res) => {
|
||||
/**
|
||||
* Upload a single file to GitLab's project uploads API.
|
||||
* 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`;
|
||||
|
||||
try {
|
||||
const parsed = new URL(apiUrl);
|
||||
const transport = parsed.protocol === 'https:' ? https : http;
|
||||
|
||||
const boundary = `----FormBoundary${Date.now()}${Math.random().toString(36).slice(2)}`;
|
||||
const fileContent = fs.readFileSync(filePath);
|
||||
|
||||
const header = Buffer.from(
|
||||
`--${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 reqOpts = {
|
||||
method: 'POST',
|
||||
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 apiReq = transport.request(reqOpts, (apiRes) => {
|
||||
let data = '';
|
||||
apiRes.on('data', chunk => data += chunk);
|
||||
apiRes.on('end', () => {
|
||||
try {
|
||||
const result = JSON.parse(data);
|
||||
if (apiRes.statusCode === 201 && result.markdown) {
|
||||
resolve(result);
|
||||
} else {
|
||||
console.error(`[Feedback] GitLab upload returned ${apiRes.statusCode}:`, data);
|
||||
resolve(null);
|
||||
}
|
||||
} catch {
|
||||
console.error('[Feedback] GitLab upload returned invalid JSON:', data);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
apiReq.on('error', (err) => {
|
||||
console.error('[Feedback] GitLab upload request error:', err.message);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
apiReq.write(body);
|
||||
apiReq.end();
|
||||
} catch (err) {
|
||||
console.error('[Feedback] GitLab upload error:', err.message);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) {
|
||||
return res.status(503).json({ error: 'Feedback integration not configured' });
|
||||
}
|
||||
@@ -32,7 +162,16 @@ function createFeedbackRouter() {
|
||||
const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request';
|
||||
const username = req.user?.username || 'unknown';
|
||||
|
||||
const body = [
|
||||
// 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}`,
|
||||
@@ -40,7 +179,22 @@ function createFeedbackRouter() {
|
||||
'---',
|
||||
'',
|
||||
description,
|
||||
].filter(Boolean).join('\n');
|
||||
].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}`,
|
||||
@@ -50,7 +204,6 @@ function createFeedbackRouter() {
|
||||
|
||||
const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`;
|
||||
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const parsed = new URL(apiUrl);
|
||||
const transport = parsed.protocol === 'https:' ? https : http;
|
||||
@@ -102,6 +255,9 @@ function createFeedbackRouter() {
|
||||
} catch (err) {
|
||||
console.error('[Feedback] Request failed:', err.message);
|
||||
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 createCardApiRouter = require('./routes/cardApi');
|
||||
const createFeedbackRouter = require('./routes/feedback');
|
||||
const createWebhooksRouter = require('./routes/webhooks');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -230,6 +231,9 @@ app.use('/api/card', createCardApiRouter());
|
||||
// Feedback routes — bug reports and feature requests to GitLab
|
||||
app.use('/api/feedback', createFeedbackRouter());
|
||||
|
||||
// GitLab webhook routes — receives issue lifecycle events (no auth required)
|
||||
app.use('/api/webhooks', createWebhooksRouter());
|
||||
|
||||
// ========== CVE ENDPOINTS ==========
|
||||
|
||||
// Get all CVEs with optional filters (authenticated users)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Bug, Lightbulb, Send, Loader, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import React, { useState, useRef } from '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.
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
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 }) {
|
||||
const [type, setType] = useState(defaultType || 'bug');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [screenshots, setScreenshots] = useState([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(null);
|
||||
const fileInputRef = useRef(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) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !description.trim()) {
|
||||
@@ -147,16 +230,21 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
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`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
page: currentPage || null,
|
||||
}),
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
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);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setScreenshots([]);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -176,6 +265,7 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag
|
||||
setSuccess(null);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setScreenshots([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -293,6 +383,43 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag
|
||||
/>
|
||||
</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 */}
|
||||
{currentPage && (
|
||||
<div style={{
|
||||
|
||||
Reference in New Issue
Block a user