File size: 5,955 Bytes
3d7d9b5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | import { createContext, useContext, useState, useCallback, useEffect, type ReactNode, Component, type ErrorInfo } from 'react';
export type ToastLevel = 'info' | 'success' | 'error';
interface Toast {
id: string;
message: string;
level: ToastLevel;
}
interface ToastContextType {
toasts: Toast[];
notify: (message: string, level?: ToastLevel, duration?: number) => void;
notifyError: (message: string) => void;
notifySuccess: (message: string) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const dismiss = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const notify = useCallback((message: string, level: ToastLevel = 'info', duration = 4000) => {
const id = crypto.randomUUID();
setToasts(prev => [...prev.slice(-4), { id, message, level }]);
setTimeout(() => dismiss(id), duration);
}, [dismiss]);
const notifyError = useCallback((message: string) => notify(message, 'error', 6000), [notify]);
const notifySuccess = useCallback((message: string) => notify(message, 'success', 3000), [notify]);
// Listen for global error events from store/other modules that can't access context directly
useEffect(() => {
const handler = (e: Event) => {
const msg = (e as CustomEvent).detail;
if (msg) notifyError(String(msg));
};
window.addEventListener('muse:error', handler);
window.addEventListener('muse:success', (e: Event) => notifySuccess(String((e as CustomEvent).detail)));
window.addEventListener('muse:info', (e: Event) => notify(String((e as CustomEvent).detail)));
return () => {
window.removeEventListener('muse:error', handler);
};
}, [notifyError, notifySuccess, notify]);
return (
<ToastContext.Provider value={{ toasts, notify, notifyError, notifySuccess, dismiss }}>
{children}
{toasts.length > 0 && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[9999] flex flex-col gap-2 items-center pointer-events-none">
{toasts.map(toast => (
<div
key={toast.id}
className={`pointer-events-auto px-4 py-2.5 rounded-xl shadow-2xl backdrop-blur-md border text-[12px] font-medium flex items-center gap-2 max-w-[420px] cursor-pointer transition-all ${
toast.level === 'error' ? 'bg-[#FF453A]/15 border-[#FF453A]/30 text-[#FF6961]' :
toast.level === 'success' ? 'bg-[#30D158]/15 border-[#30D158]/30 text-[#30D158]' :
'bg-[#1C1C1E]/90 border-white/10 text-white/90'
}`}
onClick={() => dismiss(toast.id)}
>
{toast.level === 'error' && <span className="shrink-0 text-[14px]">⚠</span>}
{toast.level === 'success' && <span className="shrink-0 text-[14px]">✓</span>}
<span className="break-words">{toast.message}</span>
</div>
))}
</div>
)}
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
/**
* Safe invoke wrapper — surfaces Rust command errors as toast notifications.
* Use instead of raw invoke().catch(() => {}).
*/
export async function safeInvoke<T>(
command: string,
args: Record<string, unknown> = {},
options?: { silent?: boolean; label?: string }
): Promise<T | undefined> {
const { invoke } = await import('@tauri-apps/api/core');
try {
return await invoke<T>(command, args);
} catch (err) {
const message = typeof err === 'string' ? err : (err as Error)?.message || 'Unknown error';
if (!options?.silent) {
const label = options?.label || command.replace(/_/g, ' ');
window.dispatchEvent(new CustomEvent('muse:error', { detail: `${label}: ${message}` }));
}
console.error(`[invoke:${command}]`, err);
return undefined;
}
}
/**
* React Error Boundary — catches render crashes and shows recovery UI.
*/
interface ErrorBoundaryProps { children: ReactNode; fallback?: ReactNode; }
interface ErrorBoundaryState { hasError: boolean; error: Error | null; }
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false, error: null }; }
static getDerivedStateFromError(error: Error) { return { hasError: true, error }; }
componentDidCatch(error: Error, info: ErrorInfo) { console.error('[ErrorBoundary]', error, info.componentStack); }
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="w-screen h-screen bg-[#0A0A0B] flex items-center justify-center">
<div className="text-center max-w-md">
<div className="text-[#FF453A] text-4xl mb-4">⚠</div>
<h1 className="text-white text-xl font-semibold mb-2">Something went wrong</h1>
<p className="text-white/50 text-sm mb-4">{this.state.error?.message || 'An unexpected error occurred'}</p>
<button onClick={() => { this.setState({ hasError: false, error: null }); }} className="px-4 py-2 bg-white/10 hover:bg-white/15 border border-white/10 rounded-lg text-white text-sm font-medium transition-colors">Try Again</button>
<button onClick={() => window.location.reload()} className="ml-3 px-4 py-2 bg-[#0A84FF]/20 hover:bg-[#0A84FF]/30 border border-[#0A84FF]/30 rounded-lg text-[#0A84FF] text-sm font-medium transition-colors">Reload App</button>
</div>
</div>
);
}
return this.props.children;
}
}
|