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:
@@ -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