init studio admin frontend
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user