139 lines
4.2 KiB
TypeScript
139 lines
4.2 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode
|
|
} from 'react';
|
|
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react';
|
|
import { cn } from '../lib/utils';
|
|
|
|
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
|
|
|
type ToastInput = {
|
|
type?: ToastType;
|
|
title?: string;
|
|
message: string;
|
|
duration?: number;
|
|
};
|
|
|
|
type ToastItem = Required<Omit<ToastInput, 'duration'>> & {
|
|
id: string;
|
|
};
|
|
|
|
type ToastContextValue = {
|
|
toast: (input: ToastInput) => string;
|
|
dismissToast: (id: string) => void;
|
|
};
|
|
|
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
|
|
const toastStyles: Record<ToastType, { icon: ReactNode; className: string; iconClassName: string }> = {
|
|
success: {
|
|
icon: <CheckCircle2 className="size-4" />,
|
|
className: 'border-emerald-500/25 bg-emerald-950/85',
|
|
iconClassName: 'text-emerald-300'
|
|
},
|
|
error: {
|
|
icon: <XCircle className="size-4" />,
|
|
className: 'border-rose-500/25 bg-rose-950/85',
|
|
iconClassName: 'text-rose-300'
|
|
},
|
|
info: {
|
|
icon: <Info className="size-4" />,
|
|
className: 'border-sky-500/25 bg-sky-950/85',
|
|
iconClassName: 'text-sky-300'
|
|
},
|
|
warning: {
|
|
icon: <AlertTriangle className="size-4" />,
|
|
className: 'border-amber-500/25 bg-amber-950/85',
|
|
iconClassName: 'text-amber-300'
|
|
}
|
|
};
|
|
|
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
const timers = useRef<Map<string, number>>(new Map());
|
|
|
|
const dismissToast = useCallback((id: string) => {
|
|
const timer = timers.current.get(id);
|
|
if (timer) window.clearTimeout(timer);
|
|
timers.current.delete(id);
|
|
setToasts((prev) => prev.filter((toastItem) => toastItem.id !== id));
|
|
}, []);
|
|
|
|
const toast = useCallback(
|
|
({ type = 'info', title = '', message, duration = 3500 }: ToastInput) => {
|
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
const toastItem: ToastItem = { id, type, title, message };
|
|
|
|
setToasts((prev) => [toastItem, ...prev].slice(0, 4));
|
|
|
|
if (duration > 0) {
|
|
const timer = window.setTimeout(() => dismissToast(id), duration);
|
|
timers.current.set(id, timer);
|
|
}
|
|
|
|
return id;
|
|
},
|
|
[dismissToast]
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
timers.current.forEach((timer) => window.clearTimeout(timer));
|
|
timers.current.clear();
|
|
};
|
|
}, []);
|
|
|
|
const contextValue = useMemo(() => ({ toast, dismissToast }), [toast, dismissToast]);
|
|
|
|
return (
|
|
<ToastContext.Provider value={contextValue}>
|
|
{children}
|
|
<div className="pointer-events-none fixed right-4 top-4 z-[80] flex w-[min(calc(100vw-2rem),380px)] flex-col gap-2">
|
|
{toasts.map((toastItem) => {
|
|
const style = toastStyles[toastItem.type];
|
|
|
|
return (
|
|
<div
|
|
key={toastItem.id}
|
|
className={cn(
|
|
'toast-enter pointer-events-auto flex gap-3 rounded-lg border px-4 py-3 text-[14px] text-foreground shadow-2xl shadow-black/30 backdrop-blur-md',
|
|
style.className
|
|
)}
|
|
>
|
|
<div className={cn('mt-1 shrink-0', style.iconClassName)}>{style.icon}</div>
|
|
<div className="min-w-0 flex-1 pt-0.5">
|
|
{toastItem.title ? <p className="text-sm font-bold leading-5">{toastItem.title}</p> : null}
|
|
<p className="break-words text-[13px] leading-5 text-muted-foreground">{toastItem.message}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
|
aria-label="토스트 닫기"
|
|
onClick={() => dismissToast(toastItem.id)}
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ToastContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useToast() {
|
|
const context = useContext(ToastContext);
|
|
|
|
if (!context) {
|
|
throw new Error('useToast는 ToastProvider 내부에서 사용해야 합니다.');
|
|
}
|
|
|
|
return context;
|
|
}
|