musealpha / src /toast.tsx
asdf98's picture
Upload 112 files
3d7d9b5 verified
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;
}
}