122 lines
3.9 KiB
React
122 lines
3.9 KiB
React
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
const ToastContext = createContext(null);
|
|
|
|
const VARIANT_STYLES = {
|
|
success: {
|
|
container: "border-green-500/80 bg-green-950/90 text-green-100",
|
|
accent: "bg-green-400"
|
|
},
|
|
warning: {
|
|
container: "border-yellow-500/80 bg-yellow-950/90 text-yellow-100",
|
|
accent: "bg-yellow-400"
|
|
},
|
|
error: {
|
|
container: "border-red-500/80 bg-red-950/90 text-red-100",
|
|
accent: "bg-red-400"
|
|
},
|
|
info: {
|
|
container: "border-sky-500/60 bg-sky-950/90 text-sky-100",
|
|
accent: "bg-sky-400"
|
|
}
|
|
};
|
|
|
|
const DEFAULT_DURATION = 6000;
|
|
|
|
export default function ToastProvider({ children }) {
|
|
const [toasts, setToasts] = useState([]);
|
|
const timeoutsRef = useRef(new Map());
|
|
|
|
const dismissToast = useCallback((id) => {
|
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
|
|
|
const handle = timeoutsRef.current.get(id);
|
|
if (handle && typeof window !== "undefined") {
|
|
window.clearTimeout(handle);
|
|
}
|
|
timeoutsRef.current.delete(id);
|
|
}, []);
|
|
|
|
const showToast = useCallback(({ id, title, description, variant = "info", duration = DEFAULT_DURATION } = {}) => {
|
|
const toastId = id ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
const normalizedVariant = VARIANT_STYLES[variant] ? variant : "info";
|
|
|
|
setToasts((prev) => {
|
|
const withoutExisting = prev.filter((toast) => toast.id !== toastId);
|
|
return [
|
|
...withoutExisting,
|
|
{
|
|
id: toastId,
|
|
title,
|
|
description,
|
|
variant: normalizedVariant
|
|
}
|
|
];
|
|
});
|
|
|
|
if (duration !== null && duration !== Infinity && typeof window !== "undefined") {
|
|
const timeoutHandle = window.setTimeout(() => {
|
|
dismissToast(toastId);
|
|
}, duration);
|
|
|
|
const existingHandle = timeoutsRef.current.get(toastId);
|
|
if (existingHandle) {
|
|
window.clearTimeout(existingHandle);
|
|
}
|
|
timeoutsRef.current.set(toastId, timeoutHandle);
|
|
}
|
|
|
|
return toastId;
|
|
}, [dismissToast]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (typeof window === "undefined") return;
|
|
timeoutsRef.current.forEach((handle) => window.clearTimeout(handle));
|
|
timeoutsRef.current.clear();
|
|
};
|
|
}, []);
|
|
|
|
const contextValue = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);
|
|
|
|
return (
|
|
<ToastContext.Provider value={contextValue}>
|
|
{children}
|
|
<div className="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-full flex-col gap-3">
|
|
{toasts.map((toast) => {
|
|
const styles = VARIANT_STYLES[toast.variant] ?? VARIANT_STYLES.info;
|
|
return (
|
|
<div
|
|
key={toast.id}
|
|
className={`pointer-events-auto overflow-hidden rounded-lg border px-4 py-3 shadow-xl backdrop-blur ${styles.container}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<span className={`mt-1 h-2 flex-shrink-0 w-2 rounded-full ${styles.accent}`}></span>
|
|
<div className="flex-1">
|
|
{toast.title && <p className="text-sm font-semibold">{toast.title}</p>}
|
|
{toast.description && <p className="mt-1 text-sm leading-snug text-white/80">{toast.description}</p>}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => dismissToast(toast.id)}
|
|
className="ml-2 rounded-md p-1 text-xs uppercase tracking-wide text-white/60 transition hover:text-white/90"
|
|
>
|
|
Schließen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ToastContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useToast() {
|
|
const context = useContext(ToastContext);
|
|
if (!context) {
|
|
throw new Error('useToast must be used within a ToastProvider');
|
|
}
|
|
return context;
|
|
}
|