| 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]);
|
|
|
|
|
| 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;
|
| }
|
|
|
| |
| |
| |
|
|
| 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;
|
| }
|
| }
|
|
|
| |
| |
|
|
| 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;
|
| }
|
| }
|
|
|