Files
NetMesh/components/ui/toast.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

195 lines
7.1 KiB
TypeScript

import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { setNotify } from '../../application/notification';
import { cn } from '../../lib/utils';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
title?: string;
message: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
interface ToastActionsValue {
showToast: (toast: Omit<Toast, 'id'>) => void;
dismissToast: (id: string) => void;
}
interface ToastStateValue {
toasts: Toast[];
}
const ToastActionsContext = createContext<ToastActionsValue | null>(null);
const ToastStateContext = createContext<ToastStateValue | null>(null);
/** Actions-only subscription — does not re-render when the toast list changes. */
export const useToastActions = () => {
const context = useContext(ToastActionsContext);
if (!context) {
throw new Error('useToastActions must be used within a ToastProvider');
}
return context;
};
/** Full toast API. Prefer useToastActions when you do not need the list. */
export const useToast = () => {
const actions = useContext(ToastActionsContext);
const state = useContext(ToastStateContext);
if (!actions || !state) {
throw new Error('useToast must be used within a ToastProvider');
}
return { ...state, ...actions };
};
// Simple hook for components that may not be inside ToastProvider
let globalShowToast: ((toast: Omit<Toast, 'id'>) => void) | null = null;
export interface ToastOptions {
title?: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
export const toast = {
success: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'success', message, duration: 3000, ...options });
},
error: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'error', message, duration: 5000, ...options });
},
warning: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'warning', message, duration: 4000, ...options });
},
info: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'info', message, duration: 3000, ...options });
},
};
const TOAST_ICONS: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="h-4 w-4 text-emerald-500" />,
error: <AlertCircle className="h-4 w-4 text-red-500" />,
warning: <AlertTriangle className="h-4 w-4 text-yellow-500" />,
info: <Info className="h-4 w-4 text-blue-500" />,
};
const TOAST_STYLES: Record<ToastType, string> = {
success: 'border-emerald-600 bg-emerald-50 dark:bg-emerald-950',
error: 'border-red-600 bg-red-50 dark:bg-red-950',
warning: 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950',
info: 'border-blue-600 bg-blue-50 dark:bg-blue-950',
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((nextToast: Omit<Toast, 'id'>) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const created: Toast = { ...nextToast, id };
setToasts(prev => [...prev, created]);
// Auto dismiss
if (nextToast.duration !== 0) {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, nextToast.duration || 4000);
}
}, []);
const dismissToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const actionsValue = useMemo<ToastActionsValue>(
() => ({ showToast, dismissToast }),
[showToast, dismissToast],
);
const stateValue = useMemo<ToastStateValue>(
() => ({ toasts }),
[toasts],
);
// Register global toast function
useEffect(() => {
globalShowToast = showToast;
setNotify(toast);
return () => {
globalShowToast = null;
};
}, [showToast]);
return (
<ToastActionsContext.Provider value={actionsValue}>
<ToastStateContext.Provider value={stateValue}>
{children}
<ToastContainer />
</ToastStateContext.Provider>
</ToastActionsContext.Provider>
);
};
const ToastContainer: React.FC = () => {
const { toasts } = useContext(ToastStateContext) ?? { toasts: [] as Toast[] };
const actions = useContext(ToastActionsContext);
const onDismiss = actions?.dismissToast;
if (toasts.length === 0 || !onDismiss) return null;
const handleToastClick = (t: Toast) => {
if (t.onClick) {
t.onClick();
onDismiss(t.id);
}
};
return (
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div
key={t.id}
className={cn(
"flex items-start gap-3 p-3 rounded-lg border shadow-lg",
"bg-card animate-in slide-in-from-right-5 fade-in duration-200",
TOAST_STYLES[t.type],
t.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
)}
onClick={() => handleToastClick(t)}
role={t.onClick ? "button" : undefined}
tabIndex={t.onClick ? 0 : undefined}
>
<div className="flex-shrink-0 mt-0.5">
{TOAST_ICONS[t.type]}
</div>
<div className="flex-1 min-w-0">
{t.title && (
<div className="text-sm font-medium text-foreground">{t.title}</div>
)}
<div className="text-sm text-muted-foreground break-words">{t.message}</div>
{t.actionLabel && t.onClick && (
<div className="text-xs font-medium text-primary mt-1">{t.actionLabel} </div>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); onDismiss(t.id); }}
className="flex-shrink-0 p-1 rounded hover:bg-secondary/80 transition-colors"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
))}
</div>
);
};
export default ToastProvider;