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 { 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,6 +70,8 @@ export function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupLayout />}>
|
||||
@@ -93,5 +97,7 @@ export function App() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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