platform/lms-adm-fe release promotion snapshot

This commit is contained in:
2026-07-07 09:15:47 +00:00
parent 1bf81f86bb
commit d029bdeda7
59 changed files with 12096 additions and 2 deletions
+88
View File
@@ -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>
);
}