From 7fbc692678210b9e1620c984d6587b085b11f0fd Mon Sep 17 00:00:00 2001 From: Pablo Date: Tue, 1 Sep 2026 15:52:51 -0500 Subject: [PATCH] feat(frontend): add toast notifications + error boundary - ToastProvider with success/error/info toasts (4s auto-dismiss) - Shows toast on analysis complete (success with count or error) - ErrorBoundary wraps app with retry UI - Fixed main.tsx to include both providers --- frontend/src/App.tsx | 11 ++- frontend/src/components/ErrorBoundary.tsx | 61 ++++++++++++++++ frontend/src/components/Toast.tsx | 89 +++++++++++++++++++++++ frontend/src/main.tsx | 8 +- 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 frontend/src/components/Toast.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca621c8..c311e06 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import FeatureCards from "./components/FeatureCards" import HowItWorks from "./components/HowItWorks" import ExampleRepos from "./components/ExampleRepos" import { usePrefersReducedMotion } from "./hooks/usePrefersReducedMotion" +import { useToast } from "./components/Toast" function useTheme() { const [dark, setDark] = useState(() => { @@ -37,6 +38,7 @@ type TabKey = (typeof TABS)[number] export default function App() { const { dark, toggle } = useTheme() const reducedMotion = usePrefersReducedMotion() + const { addToast } = useToast() const [loading, setLoading] = useState(false) const [error, setError] = useState("") const [result, setResult] = useState(null) @@ -60,8 +62,15 @@ export default function App() { try { const data = await analyze(url, br) setResult(data) + if (data.total_violations === 0) { + addToast("No violations found — architecture looks clean!", "success") + } else { + addToast(`Found ${data.total_violations} violation(s)`, "info") + } } catch (err) { - setError(err instanceof Error ? err.message : "Analysis failed") + const msg = err instanceof Error ? err.message : "Analysis failed" + setError(msg) + addToast(msg, "error") } finally { setLoading(false) } diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..a0c2982 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,61 @@ +import { Component, type ErrorInfo, type ReactNode } from "react" +import { AlertTriangle } from "lucide-react" + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export default 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, errorInfo: ErrorInfo) { + console.error("[ErrorBoundary]", error, errorInfo) + } + + handleReset = () => { + this.setState({ hasError: false, error: null }) + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + + return ( +
+
+ +

+ Something went wrong +

+

+ {this.state.error?.message || "An unexpected error occurred"} +

+ +
+
+ ) + } + + return this.props.children + } +} diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..7206b4c --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,89 @@ +import { useState, useCallback, createContext, useContext, useEffect } from "react" +import { X, CheckCircle, AlertTriangle, Info } from "lucide-react" + +export interface Toast { + id: string + message: string + type: "success" | "error" | "info" +} + +interface ToastContextValue { + toasts: Toast[] + addToast: (message: string, type?: Toast["type"]) => void + removeToast: (id: string) => void +} + +const ToastContext = createContext(null) + +export function useToast() { + const ctx = useContext(ToastContext) + if (!ctx) throw new Error("useToast must be used within ToastProvider") + return ctx +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]) + + const addToast = useCallback((message: string, type: Toast["type"] = "info") => { + const id = Math.random().toString(36).slice(2) + setToasts((prev) => [...prev, { id, message, type }]) + }, []) + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, []) + + return ( + + {children} + + + ) +} + +function ToastContainer({ toasts, onRemove }: { toasts: Toast[]; onRemove: (id: string) => void }) { + if (toasts.length === 0) return null + + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ) +} + +function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) => void }) { + useEffect(() => { + const timer = setTimeout(() => onRemove(toast.id), 4000) + return () => clearTimeout(timer) + }, [toast.id, onRemove]) + + const icons = { + success: , + error: , + info: , + } + + const bg = { + success: "border-green-400/30 bg-green-400/10", + error: "border-error/30 bg-error/10", + info: "border-brand/30 bg-brand/10", + } + + return ( +
+ {icons[toast.type]} + {toast.message} + +
+ ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..a6fb2e4 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,15 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import ErrorBoundary from './components/ErrorBoundary' +import { ToastProvider } from './components/Toast' createRoot(document.getElementById('root')!).render( - + + + + + , )