feat: add error display, error boundaries, and toast notifications

This commit is contained in:
plenarius
2026-04-20 22:12:25 -04:00
parent 72027a3024
commit 3df79d3b17
4 changed files with 265 additions and 25 deletions
+31 -25
View File
@@ -11,6 +11,8 @@ import { Setup } from "./pages/Setup";
import { IDELayout } from "./layouts/IDELayout"; import { IDELayout } from "./layouts/IDELayout";
import { SetupLayout } from "./layouts/SetupLayout"; import { SetupLayout } from "./layouts/SetupLayout";
import { FileExplorer } from "./components/editor/FileExplorer"; import { FileExplorer } from "./components/editor/FileExplorer";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { ToastProvider } from "./components/Toast";
import { api } from "./services/api"; import { api } from "./services/api";
import { useEditorState } from "./hooks/useEditorState"; import { useEditorState } from "./hooks/useEditorState";
@@ -68,30 +70,34 @@ export function App() {
); );
return ( return (
<BrowserRouter> <ToastProvider>
<Routes> <ErrorBoundary>
<Route path="/setup" element={<SetupLayout />}> <BrowserRouter>
<Route index element={<Setup />} /> <Routes>
</Route> <Route path="/setup" element={<SetupLayout />}>
<Route <Route index element={<Setup />} />
element={ </Route>
<SetupGuard> <Route
<IDELayout sidebar={sidebar} /> element={
</SetupGuard> <SetupGuard>
} <IDELayout sidebar={sidebar} />
> </SetupGuard>
<Route path="/" element={<Dashboard />} /> }
<Route >
path="/editor" <Route path="/" element={<Dashboard />} />
element={<Editor editorState={editorState} />} <Route
/> path="/editor"
<Route path="build" element={<Build />} /> element={<Editor editorState={editorState} />}
<Route path="server" element={<Server />} /> />
<Route path="toolset" element={<Toolset />} /> <Route path="build" element={<Build />} />
<Route path="repos" element={<Repos />} /> <Route path="server" element={<Server />} />
<Route path="settings" element={<Settings />} /> <Route path="toolset" element={<Toolset />} />
</Route> <Route path="repos" element={<Repos />} />
</Routes> <Route path="settings" element={<Settings />} />
</BrowserRouter> </Route>
</Routes>
</BrowserRouter>
</ErrorBoundary>
</ToastProvider>
); );
} }
@@ -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<Props, State> {
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 (
<div className="flex h-full items-center justify-center p-8">
<div className="w-full max-w-lg">
<ErrorDisplay
title="Render Error"
message={this.state.error.message}
fullLog={this.state.error.stack}
onRetry={() => this.setState({ hasError: false, error: null })}
/>
</div>
</div>
);
}
return this.props.children;
}
}
@@ -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 (
<div
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid #7f1d1d",
}}
>
<h3 className="text-lg font-semibold" style={{ color: "#fca5a5" }}>
{title}
</h3>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text)" }}>
{message}
</p>
{fullLog && (
<div className="mt-4">
<button
onClick={() => setExpanded((v) => !v)}
className="text-xs underline"
style={{ color: "var(--forge-text-secondary)" }}
>
{expanded ? "Hide Full Log" : "Show Full Log"}
</button>
{expanded && (
<pre
className="mt-2 max-h-60 overflow-auto rounded p-3 text-xs"
style={{
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
}}
>
{fullLog}
</pre>
)}
</div>
)}
<div className="mt-4 flex gap-2">
{onRetry && (
<button
onClick={onRetry}
className="rounded px-4 py-2 text-sm font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
Retry
</button>
)}
<button
onClick={copyError}
className="rounded px-4 py-2 text-sm"
style={{
border: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)",
}}
>
Copy Error
</button>
</div>
</div>
);
}
+104
View File
@@ -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<ToastContextValue>({
showToast: () => {},
});
export function useToast() {
return useContext(ToastContext);
}
const COLORS: Record<ToastType, { bg: string; border: string; text: string }> = {
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<ToastType, number | null> = {
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<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (dismissMs !== null) {
timerRef.current = setTimeout(() => onDismiss(toast.id), dismissMs);
return () => clearTimeout(timerRef.current);
}
}, [toast.id, dismissMs, onDismiss]);
return (
<div
className="flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg"
style={{ backgroundColor: bg, border: `1px solid ${border}` }}
>
<span className="flex-1 text-sm" style={{ color: text }}>
{toast.message}
</span>
<button
onClick={() => onDismiss(toast.id)}
className="text-sm opacity-60 hover:opacity-100"
style={{ color: text }}
>
{"\u2715"}
</button>
</div>
);
}
let nextId = 0;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
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 (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" style={{ maxWidth: "400px" }}>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={dismiss} />
))}
</div>
</ToastContext.Provider>
);
}