149 lines
5.0 KiB
TypeScript
149 lines
5.0 KiB
TypeScript
/* eslint-disable react-hooks/set-state-in-effect */
|
|
// Toast Notification System - Slide-in notifications from top-right
|
|
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useSyncExternalStore } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import {
|
|
subscribeToToasts,
|
|
type Toast,
|
|
} from "./toast-store";
|
|
|
|
interface ToastNotificationProps {
|
|
toast: Toast;
|
|
onDismiss: (id: string) => void;
|
|
}
|
|
|
|
function ToastItem({ toast, onDismiss }: ToastNotificationProps) {
|
|
const [progress, setProgress] = useState(100);
|
|
const [isExiting, setIsExiting] = useState(false);
|
|
const duration = toast.duration ?? 5000;
|
|
|
|
useEffect(() => {
|
|
const startTime = Date.now();
|
|
const interval = setInterval(() => {
|
|
const elapsed = Date.now() - startTime;
|
|
const remaining = Math.max(0, 100 - (elapsed / duration) * 100);
|
|
setProgress(remaining);
|
|
|
|
if (remaining === 0) {
|
|
clearInterval(interval);
|
|
setIsExiting(true);
|
|
setTimeout(() => onDismiss(toast.id), 300);
|
|
}
|
|
}, 50);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [toast.id, duration, onDismiss]);
|
|
|
|
const handleDismiss = () => {
|
|
setIsExiting(true);
|
|
setTimeout(() => onDismiss(toast.id), 300);
|
|
};
|
|
|
|
const icons = {
|
|
success: (
|
|
<svg className="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
),
|
|
error: (
|
|
<svg className="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
),
|
|
warning: (
|
|
<svg className="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
),
|
|
info: (
|
|
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
),
|
|
}[toast.type];
|
|
|
|
const bgColors = {
|
|
success: "bg-green-50 border-green-200",
|
|
error: "bg-red-50 border-red-200",
|
|
warning: "bg-amber-50 border-amber-200",
|
|
info: "bg-blue-50 border-blue-200",
|
|
}[toast.type];
|
|
|
|
return (
|
|
<div
|
|
className={`relative overflow-hidden rounded-xl border-2 shadow-lg backdrop-blur-sm transition-all duration-300 ${
|
|
isExiting ? "translate-x-full opacity-0" : "translate-x-0 opacity-100"
|
|
} ${bgColors}`}
|
|
style={{ minWidth: "320px", maxWidth: "420px" }}
|
|
>
|
|
<div className="flex items-start gap-3 p-4">
|
|
<div className="shrink-0 mt-0.5">{icons}</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-semibold text-sm text-stone-900">{toast.title}</p>
|
|
{toast.message && (
|
|
<p className="mt-1 text-sm text-stone-600 leading-relaxed">{toast.message}</p>
|
|
)}
|
|
{/* Progress bar */}
|
|
<div className="mt-2 h-1 w-full bg-stone-200 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-current opacity-40 transition-all duration-100"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleDismiss}
|
|
className="shrink-0 text-stone-400 hover:text-stone-600 transition-colors"
|
|
aria-label="Dismiss notification"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ToastNotificationContainer() {
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
// Track mount via useSyncExternalStore so we don't pay for an
|
|
// extra render with `mounted=false` (and no toast UI) before the
|
|
// useEffect fires.
|
|
const mounted = useSyncExternalStore(
|
|
() => () => {},
|
|
() => true,
|
|
() => false,
|
|
);
|
|
|
|
useEffect(() => {
|
|
|
|
const handleNewToast = (newToast: Toast) => {
|
|
setToasts((prev) => [...prev, newToast]);
|
|
};
|
|
|
|
const unsubscribe = subscribeToToasts(handleNewToast);
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
const handleDismiss = useCallback((id: string) => {
|
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
}, []);
|
|
|
|
if (!mounted) return null;
|
|
|
|
return createPortal(
|
|
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-3 pointer-events-none">
|
|
{toasts.map((t) => (
|
|
<div key={t.id} className="pointer-events-auto">
|
|
<ToastItem toast={t} onDismiss={handleDismiss} />
|
|
</div>
|
|
))}
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|