mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-31 06:32:18 +00:00
c914eeb7b3
Refs #17
226 lines
8.0 KiB
TypeScript
226 lines
8.0 KiB
TypeScript
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef, useLayoutEffect } from 'react';
|
|
import { CheckCircle2, AlertCircle, Info, XCircle, X } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
export type ToastVariant = 'success' | 'info' | 'warning' | 'error';
|
|
|
|
const MAX_VISIBLE_TOASTS = 4;
|
|
const DEFAULT_TOAST_DURATION_MS = 7000;
|
|
const ERROR_TOAST_DURATION_MS = 10000;
|
|
const TOAST_EXIT_DURATION_MS = 220;
|
|
|
|
export interface ToastMessage {
|
|
id: string;
|
|
message: React.ReactNode;
|
|
variant?: ToastVariant;
|
|
duration?: number;
|
|
isActionable?: boolean;
|
|
onDismiss?: () => void;
|
|
}
|
|
|
|
interface ToastState extends ToastMessage {
|
|
exiting?: boolean;
|
|
}
|
|
|
|
interface ToastContextType {
|
|
addToast: (toast: Omit<ToastMessage, 'id'>) => string;
|
|
removeToast: (id: string) => void;
|
|
}
|
|
|
|
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
|
|
|
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
|
const [toasts, setToasts] = useState<ToastState[]>([]);
|
|
const nextToastId = useRef(0);
|
|
|
|
const addToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
|
|
nextToastId.current += 1;
|
|
const id = `toast-${nextToastId.current}`;
|
|
setToasts(prev => {
|
|
const next = [...prev, { ...toast, id }];
|
|
return next.slice(-MAX_VISIBLE_TOASTS);
|
|
});
|
|
return id;
|
|
}, []);
|
|
|
|
const removeToast = useCallback((id: string) => {
|
|
setToasts(prev => {
|
|
// Prevent multiple exit calls and re-renders for the same ID
|
|
if (prev.find(t => t.id === id)?.exiting) return prev;
|
|
return prev.map(t => t.id === id ? { ...t, exiting: true } : t);
|
|
});
|
|
}, []);
|
|
|
|
const removeToastCompletely = useCallback((id: string) => {
|
|
setToasts(prev => prev.filter(t => t.id !== id));
|
|
}, []);
|
|
|
|
return (
|
|
<ToastContext.Provider value={{ addToast, removeToast }}>
|
|
{children}
|
|
<ToastContainer toasts={toasts} removeToast={removeToast} removeToastCompletely={removeToastCompletely} />
|
|
</ToastContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useToast = () => {
|
|
const context = useContext(ToastContext);
|
|
if (!context) throw new Error('useToast must be used within ToastProvider');
|
|
return context;
|
|
};
|
|
|
|
const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void; removeToastCompletely: (id: string) => void }> = ({ toast, removeToast, removeToastCompletely }) => {
|
|
const { t } = useTranslation();
|
|
const [isMounted, setIsMounted] = useState(false);
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const timerStartedAt = useRef<number | null>(null);
|
|
const remainingDuration = useRef<number | null>(null);
|
|
const onDismissCalled = useRef(false);
|
|
|
|
useLayoutEffect(() => {
|
|
const frame = requestAnimationFrame(() => setIsMounted(true));
|
|
return () => cancelAnimationFrame(frame);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (remainingDuration.current === null) {
|
|
remainingDuration.current = getToastDuration(toast);
|
|
}
|
|
|
|
if (toast.exiting || isHovered || remainingDuration.current === null) {
|
|
return;
|
|
}
|
|
|
|
timerStartedAt.current = Date.now();
|
|
const timer = setTimeout(() => {
|
|
removeToast(toast.id);
|
|
}, remainingDuration.current);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
if (timerStartedAt.current !== null && remainingDuration.current !== null) {
|
|
remainingDuration.current = Math.max(0, remainingDuration.current - (Date.now() - timerStartedAt.current));
|
|
timerStartedAt.current = null;
|
|
}
|
|
};
|
|
}, [toast, isHovered, removeToast]);
|
|
|
|
useEffect(() => {
|
|
const dismiss = () => {
|
|
if (onDismissCalled.current) return;
|
|
onDismissCalled.current = true;
|
|
toast.onDismiss?.();
|
|
};
|
|
if (toast.exiting) dismiss();
|
|
return dismiss;
|
|
}, [toast.exiting, toast.onDismiss]);
|
|
|
|
useEffect(() => {
|
|
if (toast.exiting) {
|
|
const fallbackTimer = setTimeout(() => {
|
|
removeToastCompletely(toast.id);
|
|
}, TOAST_EXIT_DURATION_MS + 100);
|
|
return () => clearTimeout(fallbackTimer);
|
|
}
|
|
}, [toast.exiting, toast.id, removeToastCompletely]);
|
|
|
|
const variant = toast.variant || 'info';
|
|
const role = variant === 'info' ? 'status' : 'alert';
|
|
const ariaLive = variant === 'info' ? 'polite' : 'assertive';
|
|
|
|
const variantStyles = {
|
|
success: {
|
|
accent: 'bg-emerald-500',
|
|
icon: 'text-emerald-500',
|
|
},
|
|
info: {
|
|
accent: 'bg-blue-500',
|
|
icon: 'text-blue-500',
|
|
},
|
|
warning: {
|
|
accent: 'bg-amber-500',
|
|
icon: 'text-amber-500',
|
|
},
|
|
error: {
|
|
accent: 'bg-red-500',
|
|
icon: 'text-red-500',
|
|
},
|
|
};
|
|
|
|
const icons = {
|
|
success: <CheckCircle2 className="w-5 h-5 shrink-0" strokeWidth={2.5} />,
|
|
error: <XCircle className="w-5 h-5 shrink-0" strokeWidth={2.5} />,
|
|
warning: <AlertCircle className="w-5 h-5 shrink-0" strokeWidth={2.5} />,
|
|
info: <Info className="w-5 h-5 shrink-0" strokeWidth={2.5} />,
|
|
};
|
|
|
|
const style = variantStyles[variant];
|
|
const Icon = icons[variant];
|
|
|
|
const isVisible = isMounted && !toast.exiting;
|
|
const transitionTiming = isVisible
|
|
? '220ms cubic-bezier(0.16, 1, 0.3, 1)'
|
|
: `${TOAST_EXIT_DURATION_MS}ms cubic-bezier(0.4, 0, 1, 1)`;
|
|
|
|
return (
|
|
<div
|
|
className="grid w-full overflow-hidden"
|
|
style={{
|
|
gridTemplateRows: isVisible ? '1fr' : '0fr',
|
|
marginBottom: isVisible ? 12 : 0,
|
|
transition: `grid-template-rows ${transitionTiming}, margin-bottom ${transitionTiming}`,
|
|
}}
|
|
onTransitionEnd={(e) => {
|
|
if (toast.exiting && e.target === e.currentTarget && e.propertyName === 'grid-template-rows') {
|
|
removeToastCompletely(toast.id);
|
|
}
|
|
}}
|
|
>
|
|
<div className="min-h-0 overflow-hidden">
|
|
<div
|
|
role={role}
|
|
aria-live={ariaLive}
|
|
className={`app-toast-item relative ${isVisible ? 'pointer-events-auto' : 'pointer-events-none'} flex w-full min-w-0 items-start gap-3 overflow-hidden rounded-lg border border-border-color bg-surface-overlay px-4 py-3 text-[14px] leading-relaxed text-text-primary shadow-[0_18px_50px_rgba(0,0,0,0.22)] backdrop-blur-xl`}
|
|
style={{
|
|
opacity: isVisible ? 1 : 0,
|
|
transform: isVisible ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.985)',
|
|
transformOrigin: 'bottom right',
|
|
transition: `opacity ${transitionTiming}, transform ${transitionTiming}, box-shadow ${transitionTiming}`,
|
|
}}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
onFocus={() => setIsHovered(true)}
|
|
onBlur={() => setIsHovered(false)}
|
|
>
|
|
<div className={`absolute inset-y-0 left-0 w-1 ${style.accent}`} />
|
|
<div className={`mt-0.5 shrink-0 ${style.icon}`}>{Icon}</div>
|
|
<div className="min-w-0 flex-1 break-words font-medium tracking-normal text-text-primary">{toast.message}</div>
|
|
<button
|
|
onClick={() => removeToast(toast.id)}
|
|
className="ml-2 mt-0.5 shrink-0 rounded-md p-1 text-text-secondary opacity-70 transition-all hover:bg-item-hover hover:text-text-primary hover:opacity-100 active:scale-95"
|
|
aria-label={t($ => $.actions.dismissNotification)}
|
|
>
|
|
<X className="w-4 h-4" strokeWidth={2.5} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const getToastDuration = (toast: ToastState): number | null => {
|
|
if (toast.duration === 0 || toast.isActionable) return null;
|
|
if (typeof toast.duration === 'number') return Math.max(0, toast.duration);
|
|
return toast.variant === 'error' ? ERROR_TOAST_DURATION_MS : DEFAULT_TOAST_DURATION_MS;
|
|
};
|
|
|
|
const ToastContainer: React.FC<{ toasts: ToastState[]; removeToast: (id: string) => void; removeToastCompletely: (id: string) => void }> = ({ toasts, removeToast, removeToastCompletely }) => {
|
|
return (
|
|
<div className="fixed bottom-8 left-4 right-4 z-[100] flex flex-col items-end pointer-events-none sm:left-auto sm:right-6 sm:w-full sm:max-w-[420px]">
|
|
{toasts.map(toast => (
|
|
<ToastItem key={toast.id} toast={toast} removeToast={removeToast} removeToastCompletely={removeToastCompletely} />
|
|
))}
|
|
</div>
|
|
);
|
|
};
|