Toast Notifications

This commit is contained in:
root
2025-10-09 10:30:26 +00:00
parent db46b366ab
commit b6579f511d
4 changed files with 384 additions and 39 deletions
+121
View File
@@ -0,0 +1,121 @@
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;
}