| 1 | "use client"; |
| 2 | |
| 3 | import { |
| 4 | createContext, |
| 5 | useContext, |
| 6 | useState, |
| 7 | useCallback, |
| 8 | useEffect, |
| 9 | } from "react"; |
| 10 | import { createPortal } from "react-dom"; |
| 11 | |
| 12 | interface ToastItem { |
| 13 | id: string; |
| 14 | message: string; |
| 15 | variant: "success" | "error" | "info"; |
| 16 | } |
| 17 | |
| 18 | interface ToastContextValue { |
| 19 | toast: (message: string, variant?: ToastItem["variant"]) => void; |
| 20 | } |
| 21 | |
| 22 | const ToastContext = createContext<ToastContextValue>({ |
| 23 | toast: () => {}, |
| 24 | }); |
| 25 | |
| 26 | export function useToast() { |
| 27 | return useContext(ToastContext); |
| 28 | } |
| 29 | |
| 30 | const variantStyles: Record< |
| 31 | ToastItem["variant"], |
| 32 | { bg: string; border: string; text: string } |
| 33 | > = { |
| 34 | success: { |
| 35 | bg: "var(--status-open-bg)", |
| 36 | border: "var(--status-open-border)", |
| 37 | text: "var(--status-open-text)", |
| 38 | }, |
| 39 | error: { |
| 40 | bg: "var(--error-bg)", |
| 41 | border: "var(--error-border)", |
| 42 | text: "var(--error-text)", |
| 43 | }, |
| 44 | info: { |
| 45 | bg: "var(--accent-subtle)", |
| 46 | border: "var(--accent)", |
| 47 | text: "var(--accent)", |
| 48 | }, |
| 49 | }; |
| 50 | |
| 51 | export function ToastProvider({ children }: { children: React.ReactNode }) { |
| 52 | const [toasts, setToasts] = useState<ToastItem[]>([]); |
| 53 | const [mounted, setMounted] = useState(false); |
| 54 | |
| 55 | useEffect(() => { |
| 56 | setMounted(true); |
| 57 | }, []); |
| 58 | |
| 59 | const toast = useCallback( |
| 60 | (message: string, variant: ToastItem["variant"] = "info") => { |
| 61 | const id = Math.random().toString(36).slice(2); |
| 62 | setToasts((prev) => [...prev, { id, message, variant }]); |
| 63 | setTimeout(() => { |
| 64 | setToasts((prev) => prev.filter((t) => t.id !== id)); |
| 65 | }, 3000); |
| 66 | }, |
| 67 | [] |
| 68 | ); |
| 69 | |
| 70 | return ( |
| 71 | <ToastContext.Provider value={{ toast }}> |
| 72 | {children} |
| 73 | {mounted && |
| 74 | createPortal( |
| 75 | <div |
| 76 | className="fixed z-50" |
| 77 | style={{ bottom: 16, right: 16, display: "flex", flexDirection: "column", gap: 8 }} |
| 78 | > |
| 79 | {toasts.map((t) => { |
| 80 | const s = variantStyles[t.variant]; |
| 81 | return ( |
| 82 | <div |
| 83 | key={t.id} |
| 84 | className="toast-enter text-sm px-4 py-3" |
| 85 | style={{ |
| 86 | backgroundColor: s.bg, |
| 87 | border: `1px solid ${s.border}`, |
| 88 | color: s.text, |
| 89 | minWidth: 200, |
| 90 | maxWidth: 360, |
| 91 | }} |
| 92 | > |
| 93 | {t.message} |
| 94 | </div> |
| 95 | ); |
| 96 | })} |
| 97 | </div>, |
| 98 | document.body |
| 99 | )} |
| 100 | </ToastContext.Provider> |
| 101 | ); |
| 102 | } |
| 103 | |