platform/lms-adm-fe release promotion snapshot
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { CheckCircle2, CircleAlert, X } from 'lucide-react';
|
||||
|
||||
type Toast = {
|
||||
id: number;
|
||||
title: string;
|
||||
message?: string;
|
||||
variant?: 'success' | 'error';
|
||||
entering?: boolean;
|
||||
closing?: boolean;
|
||||
};
|
||||
|
||||
type ToastContextValue = {
|
||||
showToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
const TOAST_DURATION_MS = 2800;
|
||||
const TOAST_EXIT_MS = 180;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((current) => current.map((toast) => (toast.id === id ? { ...toast, closing: true } : toast)));
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, TOAST_EXIT_MS);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
setToasts((current) => [...current, { ...toast, id, entering: true }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.map((item) => (item.id === id ? { ...item, entering: false } : item)));
|
||||
}, 20);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ showToast }), [showToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className="fixed bottom-5 right-5 z-[80] flex w-[calc(100vw-40px)] max-w-[360px] flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) throw new Error('useToast must be used within ToastProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(onClose, TOAST_DURATION_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
|
||||
const Icon = toast.variant === 'error' ? CircleAlert : CheckCircle2;
|
||||
const iconTone = toast.variant === 'error' ? 'bg-[#e11d48] text-white' : 'bg-[#4f46e5] text-white';
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border border-[#1e293b] bg-[#111827] px-4 py-3 shadow-[0_22px_54px_rgba(15,23,42,0.32)] transition duration-180 ease-out ${toast.closing || toast.entering ? 'translate-y-2 opacity-0' : 'translate-y-0 opacity-100'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md ${iconTone}`}>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-black text-white">{toast.title}</p>
|
||||
{toast.message ? <p className="mt-1 truncate text-[12px] font-bold text-[#cbd5e1]">{toast.message}</p> : null}
|
||||
</div>
|
||||
<button className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-[#cbd5e1] hover:bg-white/10 hover:text-white" type="button" onClick={onClose} aria-label="토스트 닫기" title="토스트 닫기">
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user