134 lines
4.8 KiB
TypeScript
134 lines
4.8 KiB
TypeScript
'use client';
|
|
|
|
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
|
|
import { CheckCircle2, AlertTriangle, Info, X, AlertCircle } from 'lucide-react';
|
|
|
|
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
|
|
|
interface Toast {
|
|
id: string;
|
|
message: string;
|
|
type: ToastType;
|
|
duration?: number;
|
|
}
|
|
|
|
interface ConfirmOptions {
|
|
title: string;
|
|
message: string;
|
|
confirmLabel?: string;
|
|
cancelLabel?: string;
|
|
danger?: boolean;
|
|
}
|
|
|
|
interface ToastContextType {
|
|
toast: (message: string, type?: ToastType, duration?: number) => void;
|
|
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
|
}
|
|
|
|
const ToastContext = createContext<ToastContextType | null>(null);
|
|
|
|
export function useToast() {
|
|
const ctx = useContext(ToastContext);
|
|
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
|
return ctx;
|
|
}
|
|
|
|
const icons: Record<ToastType, React.ReactNode> = {
|
|
success: <CheckCircle2 className="w-5 h-5 text-emerald-500" />,
|
|
error: <AlertCircle className="w-5 h-5 text-red-500" />,
|
|
warning: <AlertTriangle className="w-5 h-5 text-amber-500" />,
|
|
info: <Info className="w-5 h-5 text-blue-500" />,
|
|
};
|
|
|
|
const bgColors: Record<ToastType, string> = {
|
|
success: 'bg-emerald-50 border-emerald-200',
|
|
error: 'bg-red-50 border-red-200',
|
|
warning: 'bg-amber-50 border-amber-200',
|
|
info: 'bg-blue-50 border-blue-200',
|
|
};
|
|
|
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
const [confirmState, setConfirmState] = useState<{
|
|
options: ConfirmOptions;
|
|
resolve: (value: boolean) => void;
|
|
} | null>(null);
|
|
|
|
const toast = useCallback((message: string, type: ToastType = 'info', duration = 4000) => {
|
|
const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
setToasts(prev => [...prev, { id, message, type, duration }]);
|
|
if (duration > 0) {
|
|
setTimeout(() => {
|
|
setToasts(prev => prev.filter(t => t.id !== id));
|
|
}, duration);
|
|
}
|
|
}, []);
|
|
|
|
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
|
return new Promise((resolve) => {
|
|
setConfirmState({ options, resolve });
|
|
});
|
|
}, []);
|
|
|
|
const handleConfirm = (result: boolean) => {
|
|
confirmState?.resolve(result);
|
|
setConfirmState(null);
|
|
};
|
|
|
|
const removeToast = (id: string) => {
|
|
setToasts(prev => prev.filter(t => t.id !== id));
|
|
};
|
|
|
|
return (
|
|
<ToastContext.Provider value={{ toast, confirm }}>
|
|
{children}
|
|
|
|
{/* Toast Container */}
|
|
<div className="fixed top-4 right-4 z-[100] space-y-3 pointer-events-none" style={{ maxWidth: '400px' }}>
|
|
{toasts.map((t) => (
|
|
<div
|
|
key={t.id}
|
|
className={`pointer-events-auto flex items-start gap-3 p-4 rounded-xl border shadow-lg backdrop-blur-sm ${bgColors[t.type]} animate-slide-in`}
|
|
>
|
|
<div className="flex-shrink-0 mt-0.5">{icons[t.type]}</div>
|
|
<p className="text-sm font-medium text-slate-800 flex-1">{t.message}</p>
|
|
<button onClick={() => removeToast(t.id)} className="flex-shrink-0 text-slate-400 hover:text-slate-600 transition">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Confirm Modal */}
|
|
{confirmState && (
|
|
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-[110] p-4 animate-fade-in">
|
|
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-8 animate-scale-in">
|
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-4 ${confirmState.options.danger ? 'bg-red-100' : 'bg-blue-100'}`}>
|
|
{confirmState.options.danger
|
|
? <AlertTriangle className="w-6 h-6 text-red-600" />
|
|
: <Info className="w-6 h-6 text-blue-600" />
|
|
}
|
|
</div>
|
|
<h3 className="text-lg font-bold text-slate-900 mb-2">{confirmState.options.title}</h3>
|
|
<p className="text-sm text-slate-600 mb-8">{confirmState.options.message}</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={() => handleConfirm(false)}
|
|
className="px-5 py-2.5 rounded-lg text-slate-600 font-medium hover:bg-slate-100 transition"
|
|
>
|
|
{confirmState.options.cancelLabel || 'Abbrechen'}
|
|
</button>
|
|
<button
|
|
onClick={() => handleConfirm(true)}
|
|
className={`px-5 py-2.5 rounded-lg font-medium text-white shadow-sm transition ${confirmState.options.danger ? 'bg-red-600 hover:bg-red-700' : 'bg-indigo-600 hover:bg-indigo-700'}`}
|
|
>
|
|
{confirmState.options.confirmLabel || 'Bestätigen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</ToastContext.Provider>
|
|
);
|
|
}
|