Files
studio-admin-fe/src/components/toast.tsx
T
2026-07-01 17:34:15 +09:00

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;
}