From 3df79d3b17ebd5ee9f79175c43b2fcff8a898209 Mon Sep 17 00:00:00 2001 From: plenarius Date: Mon, 20 Apr 2026 22:12:25 -0400 Subject: [PATCH] feat: add error display, error boundaries, and toast notifications --- packages/frontend/src/App.tsx | 56 +++++----- .../frontend/src/components/ErrorBoundary.tsx | 45 ++++++++ .../frontend/src/components/ErrorDisplay.tsx | 85 ++++++++++++++ packages/frontend/src/components/Toast.tsx | 104 ++++++++++++++++++ 4 files changed, 265 insertions(+), 25 deletions(-) create mode 100644 packages/frontend/src/components/ErrorBoundary.tsx create mode 100644 packages/frontend/src/components/ErrorDisplay.tsx create mode 100644 packages/frontend/src/components/Toast.tsx diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 6ae8668..c4566ee 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -11,6 +11,8 @@ import { Setup } from "./pages/Setup"; import { IDELayout } from "./layouts/IDELayout"; import { SetupLayout } from "./layouts/SetupLayout"; import { FileExplorer } from "./components/editor/FileExplorer"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import { ToastProvider } from "./components/Toast"; import { api } from "./services/api"; import { useEditorState } from "./hooks/useEditorState"; @@ -68,30 +70,34 @@ export function App() { ); return ( - - - }> - } /> - - - - - } - > - } /> - } - /> - } /> - } /> - } /> - } /> - } /> - - - + + + + + }> + } /> + + + + + } + > + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + + + + + ); } diff --git a/packages/frontend/src/components/ErrorBoundary.tsx b/packages/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..19e8eba --- /dev/null +++ b/packages/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,45 @@ +import { Component } from "react"; +import type { ReactNode, ErrorInfo } from "react"; +import { ErrorDisplay } from "./ErrorDisplay"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("ErrorBoundary caught:", error, info); + } + + render() { + if (this.state.hasError && this.state.error) { + return ( +
+
+ this.setState({ hasError: false, error: null })} + /> +
+
+ ); + } + return this.props.children; + } +} diff --git a/packages/frontend/src/components/ErrorDisplay.tsx b/packages/frontend/src/components/ErrorDisplay.tsx new file mode 100644 index 0000000..1b3ac2b --- /dev/null +++ b/packages/frontend/src/components/ErrorDisplay.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; + +interface ErrorDisplayProps { + title?: string; + message: string; + fullLog?: string; + onRetry?: () => void; +} + +export function ErrorDisplay({ + title = "Something went wrong", + message, + fullLog, + onRetry, +}: ErrorDisplayProps) { + const [expanded, setExpanded] = useState(false); + + const copyError = () => { + const text = fullLog ? `${title}\n${message}\n\n${fullLog}` : `${title}\n${message}`; + navigator.clipboard.writeText(text).catch(() => {}); + }; + + return ( +
+

+ {title} +

+

+ {message} +

+ + {fullLog && ( +
+ + {expanded && ( +
+              {fullLog}
+            
+ )} +
+ )} + +
+ {onRetry && ( + + )} + +
+
+ ); +} diff --git a/packages/frontend/src/components/Toast.tsx b/packages/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..48bfb60 --- /dev/null +++ b/packages/frontend/src/components/Toast.tsx @@ -0,0 +1,104 @@ +import { + createContext, + useContext, + useState, + useCallback, + useEffect, + useRef, +} from "react"; +import type { ReactNode } from "react"; + +type ToastType = "success" | "error" | "info"; + +interface Toast { + id: number; + message: string; + type: ToastType; +} + +interface ToastContextValue { + showToast: (message: string, type?: ToastType) => void; +} + +const ToastContext = createContext({ + showToast: () => {}, +}); + +export function useToast() { + return useContext(ToastContext); +} + +const COLORS: Record = { + success: { bg: "#052e16", border: "#166534", text: "#4ade80" }, + error: { bg: "#3b1111", border: "#7f1d1d", text: "#fca5a5" }, + info: { bg: "#1c1a00", border: "#713f12", text: "#fbbf24" }, +}; + +const AUTO_DISMISS: Record = { + success: 3000, + error: null, + info: 5000, +}; + +function ToastItem({ + toast, + onDismiss, +}: { + toast: Toast; + onDismiss: (id: number) => void; +}) { + const { bg, border, text } = COLORS[toast.type]; + const dismissMs = AUTO_DISMISS[toast.type]; + const timerRef = useRef>(); + + useEffect(() => { + if (dismissMs !== null) { + timerRef.current = setTimeout(() => onDismiss(toast.id), dismissMs); + return () => clearTimeout(timerRef.current); + } + }, [toast.id, dismissMs, onDismiss]); + + return ( +
+ + {toast.message} + + +
+ ); +} + +let nextId = 0; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((message: string, type: ToastType = "info") => { + const id = ++nextId; + setToasts((t) => [...t, { id, message, type }]); + }, []); + + const dismiss = useCallback((id: number) => { + setToasts((t) => t.filter((toast) => toast.id !== id)); + }, []); + + return ( + + {children} +
+ {toasts.map((toast) => ( + + ))} +
+
+ ); +}