feat: add error display, error boundaries, and toast notifications
This commit is contained in:
@@ -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,6 +70,8 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<ErrorBoundary>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/setup" element={<SetupLayout />}>
|
<Route path="/setup" element={<SetupLayout />}>
|
||||||
@@ -93,5 +97,7 @@ export function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user