import { useState, useRef, useCallback, useEffect } from 'react'; import { Bug, X, Loader2, CheckCircle, AlertCircle, Trash2, Upload } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { bugReportApi } from '../api/client'; type ViewState = 'form' | 'collecting' | 'submitting' | 'success' | 'error'; const LOG_COLLECTION_SECONDS = 30; const MAX_DIMENSION = 1920; const JPEG_QUALITY = 0.7; function compressImage(file: File): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { let { width, height } = img; if (width > MAX_DIMENSION || height > MAX_DIMENSION) { const scale = MAX_DIMENSION / Math.max(width, height); width = Math.round(width * scale); height = Math.round(height * scale); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { reject(new Error('No canvas context')); return; } ctx.drawImage(img, 0, 0, width, height); const dataUrl = canvas.toDataURL('image/jpeg', JPEG_QUALITY); resolve(dataUrl.replace(/^data:[^;]+;base64,/, '')); }; img.onerror = reject; img.src = URL.createObjectURL(file); }); } export function BugReportBubble() { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const [viewState, setViewState] = useState('form'); const [description, setDescription] = useState(''); const [email, setEmail] = useState(''); const [screenshot, setScreenshot] = useState(null); const [isDragging, setIsDragging] = useState(false); const [issueUrl, setIssueUrl] = useState(null); const [issueNumber, setIssueNumber] = useState(null); const [errorMessage, setErrorMessage] = useState(''); const [countdown, setCountdown] = useState(0); const modalRef = useRef(null); const fileInputRef = useRef(null); // Countdown timer for log collection phase useEffect(() => { if (viewState !== 'collecting') return; if (countdown <= 0) { setViewState('submitting'); return; } const timer = setTimeout(() => setCountdown((c) => c - 1), 1000); return () => clearTimeout(timer); }, [viewState, countdown]); const handleOpen = () => { setIsOpen(true); setViewState('form'); setDescription(''); setEmail(''); setScreenshot(null); setIssueUrl(null); setIssueNumber(null); setErrorMessage(''); }; const handleClose = () => { setIsOpen(false); }; const handleFile = useCallback(async (file: File) => { if (!file.type.startsWith('image/')) return; try { const b64 = await compressImage(file); setScreenshot(b64); } catch { // Ignore read errors } }, []); const handlePaste = useCallback((e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) handleFile(file); break; } } }, [handleFile]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); const file = e.dataTransfer.files?.[0]; if (file) handleFile(file); }, [handleFile]); const handleSubmit = async () => { if (!description.trim()) return; setCountdown(LOG_COLLECTION_SECONDS); setViewState('collecting'); try { const result = await bugReportApi.submit({ description: description.trim(), email: email.trim() || undefined, screenshot_base64: screenshot || undefined, include_support_info: true, }); if (result.success) { setIssueUrl(result.issue_url || null); setIssueNumber(result.issue_number || null); setViewState('success'); } else { setErrorMessage(result.message); setViewState('error'); } } catch (err) { setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError')); setViewState('error'); } }; return ( <> {/* Floating bubble */} {/* Slide-in panel anchored to bottom-right */} {isOpen && (
{/* Header */}

{t('bugReport.title')}

{viewState === 'form' && ( <> {/* Description */}