bug report tool — screenshot, console logs, and context sent to discord
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { Bug } from 'lucide-react';
|
||||
import { useBugReport } from './use-bug-report';
|
||||
import { BugReportDialog } from './BugReportDialog';
|
||||
|
||||
export const BugReportButton = () => {
|
||||
const bugReport = useBugReport();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={bugReport.handleOpen}
|
||||
className="fixed bottom-20 right-4 z-[90] flex h-10 w-10 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-lg transition-transform hover:scale-110"
|
||||
aria-label="Report a bug"
|
||||
>
|
||||
<Bug size={20} />
|
||||
</button>
|
||||
<BugReportDialog
|
||||
open={bugReport.open}
|
||||
capturing={bugReport.capturing}
|
||||
submitting={bugReport.submitting}
|
||||
screenshot={bugReport.screenshot}
|
||||
onClose={bugReport.handleClose}
|
||||
onSubmit={bugReport.handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
type BugReportDialogProps = {
|
||||
open: boolean;
|
||||
capturing: boolean;
|
||||
submitting: boolean;
|
||||
screenshot: Blob | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (description: string) => void;
|
||||
};
|
||||
|
||||
export const BugReportDialog = ({ open, capturing, submitting, screenshot, onClose, onSubmit }: BugReportDialogProps) => {
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit(description);
|
||||
setDescription('');
|
||||
};
|
||||
|
||||
const canSubmit = description.trim().length > 0 && !capturing && !submitting;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Report a Bug</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-md border bg-muted">
|
||||
{capturing ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : previewUrl ? (
|
||||
<img src={previewUrl} alt="Screenshot" className="max-h-48 w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||
No screenshot captured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="Describe what went wrong..."
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Console logs, browser info, and recent API errors are collected automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Submit
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toBlob } from 'html-to-image';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import type { ErrorDetails } from 'hooks/useClient';
|
||||
import { getConsoleBuffer } from '@/lib/console-buffer';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useBugReport = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [screenshot, setScreenshot] = useState<Blob | null>(null);
|
||||
const [capturing, setCapturing] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const client = useClient();
|
||||
const { user } = useAuth();
|
||||
const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
|
||||
|
||||
const handleOpen = useCallback(async () => {
|
||||
setCapturing(true);
|
||||
try {
|
||||
const blob = await toBlob(document.body, { pixelRatio: 0.75 });
|
||||
setScreenshot(blob);
|
||||
} catch (err) {
|
||||
console.error('Screenshot capture failed:', err);
|
||||
} finally {
|
||||
setCapturing(false);
|
||||
setOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setScreenshot(null);
|
||||
setCapturing(false);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (description: string) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const context = {
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
timestamp: new Date().toISOString(),
|
||||
consoleLogs: getConsoleBuffer(),
|
||||
apiError,
|
||||
user: user ? { id: user.id, email: user.email, name: user.name } : null,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('description', description);
|
||||
formData.append('context', JSON.stringify(context));
|
||||
if (screenshot) {
|
||||
formData.append('screenshot', screenshot, 'screenshot.png');
|
||||
}
|
||||
|
||||
await client.post('/bug-report', formData);
|
||||
toast.success('Bug report submitted');
|
||||
handleClose();
|
||||
} catch {
|
||||
toast.error('Failed to submit bug report');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[screenshot, client, user, apiError, handleClose],
|
||||
);
|
||||
|
||||
return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit };
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { Background } from './Background';
|
||||
import { Header } from './Header';
|
||||
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
||||
import { useIsTouch } from './useIsTouch';
|
||||
import { BugReportButton } from './BugReport/BugReportButton';
|
||||
|
||||
type DashboardLayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
@@ -21,6 +22,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
<BugReportButton />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './lib/console-buffer';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
type LogLevel = 'log' | 'warn' | 'error';
|
||||
|
||||
type LogEntry = {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const MAX_ENTRIES = 100;
|
||||
const buffer: LogEntry[] = [];
|
||||
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
function pushEntry(level: LogLevel, args: unknown[]) {
|
||||
const message = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ');
|
||||
buffer.push({ level, message, timestamp: Date.now() });
|
||||
if (buffer.length > MAX_ENTRIES) buffer.shift();
|
||||
}
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
pushEntry('log', args);
|
||||
originalLog.apply(console, args);
|
||||
};
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
pushEntry('warn', args);
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
pushEntry('error', args);
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
export const getConsoleBuffer = (): LogEntry[] => [...buffer];
|
||||
Reference in New Issue
Block a user