refactor: unify notifications into global toast

This commit is contained in:
rcourtman 2025-10-22 12:37:11 +00:00
parent f83caf8933
commit 9d1b9a78c4
6 changed files with 32 additions and 210 deletions

View file

@ -22,7 +22,6 @@ import { Alerts } from './pages/Alerts';
import { DockerHosts } from './components/Docker/DockerHosts';
import { ToastContainer } from './components/Toast/Toast';
import { ErrorBoundary } from './components/ErrorBoundary';
import NotificationContainer from './components/NotificationContainer';
import { SecurityWarning } from './components/SecurityWarning';
import { Login } from './components/Login';
import { logger } from './utils/logger';
@ -585,7 +584,6 @@ function App() {
</AppLayout>
</div>
<ToastContainer />
<NotificationContainer />
<TokenRevealDialog />
<TooltipRoot />
</DarkModeContext.Provider>

View file

@ -1,25 +0,0 @@
import { Component, For } from 'solid-js';
import { Portal } from 'solid-js/web';
import NotificationToast from './NotificationToast';
import { notificationStore } from '../stores/notifications';
const NotificationContainer: Component = () => {
return (
<Portal>
<div class="fixed top-4 right-4 z-50 space-y-2">
<For each={notificationStore.notifications}>
{(notification) => (
<NotificationToast
message={notification.message}
type={notification.type}
duration={notification.duration}
onClose={() => notificationStore.remove(notification.id)}
/>
)}
</For>
</div>
</Portal>
);
};
export default NotificationContainer;

View file

@ -1,136 +0,0 @@
import { Component, createSignal, onCleanup, onMount, Show } from 'solid-js';
interface NotificationToastProps {
message: string;
type: 'success' | 'error' | 'info';
duration?: number;
onClose?: () => void;
}
const NotificationToast: Component<NotificationToastProps> = (props) => {
const [isVisible, setIsVisible] = createSignal(true);
const [isLeaving, setIsLeaving] = createSignal(false);
const handleClose = () => {
setIsLeaving(true);
setTimeout(() => {
setIsVisible(false);
props.onClose?.();
}, 300);
};
onMount(() => {
if (props.duration && props.duration > 0) {
const timer = setTimeout(() => {
handleClose();
}, props.duration);
onCleanup(() => clearTimeout(timer));
}
});
const iconColor = () => {
switch (props.type) {
case 'success':
return 'text-green-400';
case 'error':
return 'text-red-400';
default:
return 'text-blue-400';
}
};
const icon = () => {
switch (props.type) {
case 'success':
return (
<div class="relative">
<div class="absolute inset-0 bg-green-400 rounded-full blur-xl opacity-50 animate-pulse"></div>
<svg class="w-6 h-6 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2.5"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
);
case 'error':
return (
<div class="relative">
<div class="absolute inset-0 bg-red-400 rounded-full blur-xl opacity-50 animate-pulse"></div>
<svg class="w-6 h-6 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2.5"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
);
default:
return (
<div class="relative">
<div class="absolute inset-0 bg-blue-400 rounded-full blur-xl opacity-50 animate-pulse"></div>
<svg class="w-6 h-6 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2.5"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
);
}
};
return (
<Show when={isVisible()}>
<div
class={`
fixed top-4 right-4 z-50
backdrop-blur-xl bg-white/10 dark:bg-gray-900/30
border border-white/20 dark:border-gray-700/30
px-5 py-4 rounded-2xl shadow-2xl
flex items-center gap-4
min-w-[320px] max-w-[500px]
transition-all duration-500 ease-out
${isLeaving() ? 'opacity-0 translate-x-full scale-95' : 'opacity-100 translate-x-0 scale-100'}
animate-slide-in-glass
`}
style={{
background:
'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
'box-shadow':
'0 8px 32px 0 rgba(31, 38, 135, 0.37), inset 0 0 0 1px rgba(255,255,255,0.1)',
}}
>
<div class={`${iconColor()} flex-shrink-0`}>{icon()}</div>
<div class="flex-1">
<p class="text-gray-800 dark:text-gray-100 font-medium text-sm leading-relaxed">
{props.message}
</p>
</div>
<button
type="button"
onClick={handleClose}
class="flex-shrink-0 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-white/10 rounded-lg p-1.5 transition-all duration-200"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</Show>
);
};
export default NotificationToast;

View file

@ -145,7 +145,7 @@ const Toast: Component<ToastProps> = (props) => {
// Declare global interface extension
declare global {
interface Window {
showToast: (type: ToastType, title: string, message?: string, duration?: number) => void;
showToast: (type: ToastType, title: string, message?: string, duration?: number) => string;
}
}
@ -158,8 +158,9 @@ export const ToastContainer: Component = () => {
// Expose global toast function
window.showToast = (type: ToastType, title: string, message?: string, duration?: number) => {
const id = Date.now().toString();
const id = (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Date.now().toString());
setToasts([...toasts(), { id, type, title, message, duration }]);
return id;
};
return (

View file

@ -1,44 +1,21 @@
import { createStore } from 'solid-js/store';
import { showToast } from '@/utils/toast';
export interface Notification {
id: string;
message: string;
type: 'success' | 'error' | 'info';
duration?: number;
}
const [notifications, setNotifications] = createStore<Notification[]>([]);
const DEFAULT_DURATIONS: Record<'success' | 'error' | 'info', number> = {
success: 5000,
error: 10000,
info: 5000,
};
export const notificationStore = {
notifications,
add: (notification: Omit<Notification, 'id'>) => {
const id = Date.now().toString();
setNotifications((prev) => [...prev, { ...notification, id }]);
// Auto-remove after duration
if (notification.duration && notification.duration > 0) {
setTimeout(() => {
notificationStore.remove(id);
}, notification.duration);
}
return id;
success: (message: string, duration: number = DEFAULT_DURATIONS.success) => {
return showToast('success', message, undefined, duration);
},
remove: (id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
error: (message: string, duration: number = DEFAULT_DURATIONS.error) => {
return showToast('error', message, undefined, duration);
},
success: (message: string, duration = 5000) => {
return notificationStore.add({ message, type: 'success', duration });
},
error: (message: string, duration = 10000) => {
return notificationStore.add({ message, type: 'error', duration });
},
info: (message: string, duration = 5000) => {
return notificationStore.add({ message, type: 'info', duration });
info: (message: string, duration: number = DEFAULT_DURATIONS.info) => {
return showToast('info', message, undefined, duration);
},
};

View file

@ -2,18 +2,25 @@ import type { ToastType } from '@/components/Toast/Toast';
// Global declaration is in Toast.tsx
export const showToast = (type: ToastType, title: string, message?: string, duration?: number) => {
// Use the global toast function exposed by ToastContainer
export const showToast = (
type: ToastType,
title: string,
message?: string,
duration?: number,
): string | undefined => {
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(type, title, message, duration);
} else {
// Fallback to console if toast system not ready
console.log(`[${type.toUpperCase()}] ${title}${message ? ': ' + message : ''}`);
return window.showToast(type, title, message, duration);
}
// Fallback to console if toast system not ready
console.log(`[${type.toUpperCase()}] ${title}${message ? `: ${message}` : ''}`);
return undefined;
};
// Convenience functions - only export what's used
export const showSuccess = (title: string, message?: string) =>
showToast('success', title, message);
export const showError = (title: string, message?: string) => showToast('error', title, message);
export const showInfo = (title: string, message?: string) => showToast('info', title, message);
export const showSuccess = (title: string, message?: string, duration?: number) =>
showToast('success', title, message, duration);
export const showError = (title: string, message?: string, duration?: number) =>
showToast('error', title, message, duration);
export const showInfo = (title: string, message?: string, duration?: number) =>
showToast('info', title, message, duration);