// toast.jsx — unified operation feedback для админ-панели. // // Принципы (из обсуждения): // - LOUD: операционная панель должна громко сообщать об ошибках // - dedupe одинаковых ошибок (3-сек window — частая mutation не спамит) // - severity-based TTL: ok → быстро, err → долго // - max 5 toast на экране — старые auto-dismiss // - manual dismiss кнопкой × // - не блокируют UI (поверх, не модалка) // // API: // window.__toast.push({ kind: "ok"|"info"|"warn"|"err", text: string }) // window.__toast.dismiss(id) // — компонент, монтируется один раз в (function () { "use strict"; // ============================================================ // store // ============================================================ const toasts = []; // [{id, kind, text, ts, expiresAt}] const listeners = new Set(); let nextId = 1; let cleanupTimer = null; // TTL по severity (мс). err держим долго чтобы оператор успел прочитать. const TTL = { info: 3000, ok: 2500, warn: 5000, err: 8000 }; // Dedupe window: если push с тем же kind+text прилетел в течение этого // времени с предыдущего — продлеваем существующий, не плодим новый. const DEDUPE_WINDOW_MS = 3000; // Лимит одновременных toast'ов. При переполнении дропаем самый старый. const MAX_TOASTS = 5; function _notify() { listeners.forEach((fn) => fn()); } function _ensureCleanup() { if (cleanupTimer) return; cleanupTimer = setInterval(() => { const now = Date.now(); let changed = false; for (let i = toasts.length - 1; i >= 0; i--) { if (toasts[i].expiresAt <= now) { toasts.splice(i, 1); changed = true; } } if (changed) _notify(); if (toasts.length === 0) { clearInterval(cleanupTimer); cleanupTimer = null; } }, 500); } function push(opts) { if (!opts || !opts.text) return; const kind = opts.kind || "info"; const text = String(opts.text).slice(0, 240); const now = Date.now(); // Dedupe — продлеваем TTL у существующего вместо нового const dup = toasts.find( (t) => t.kind === kind && t.text === text && now - t.ts < DEDUPE_WINDOW_MS ); if (dup) { dup.ts = now; dup.expiresAt = now + (TTL[kind] || 3000); _notify(); return dup.id; } const id = nextId++; toasts.push({ id, kind, text, ts: now, expiresAt: now + (TTL[kind] || 3000), }); // Лимит — дропаем самый старый while (toasts.length > MAX_TOASTS) toasts.shift(); _notify(); _ensureCleanup(); return id; } function dismiss(id) { const i = toasts.findIndex((t) => t.id === id); if (i >= 0) { toasts.splice(i, 1); _notify(); } } function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } function getSnapshot() { // Возвращаем стабильный snapshot — список меняется по ссылке через _notify return toasts; } // expose до того как Toaster что-то отрисует window.__toast = { push, dismiss }; // Optional hook для использования из компонентов window.useToast = function useToast() { return window.__toast; }; // ============================================================ // — компонент // ============================================================ function Toaster() { const [, force] = React.useState(0); React.useEffect(() => subscribe(() => force((n) => n + 1)), []); const list = getSnapshot(); if (list.length === 0) return null; const styleByKind = { info: "bg-zinc-900/95 border-zinc-700 text-zinc-200", ok: "bg-emerald-950/95 border-emerald-700/60 text-emerald-200", warn: "bg-amber-950/95 border-amber-700/60 text-amber-200", err: "bg-rose-950/95 border-rose-700/60 text-rose-200", }; const iconByKind = { info: "info", ok: "check", warn: "alert", err: "x" }; return ( {list.map((t) => ( {/* Icon — берём из существующего если есть */} {typeof Icon !== "undefined" && ( )} {t.text} dismiss(t.id)} className="opacity-50 hover:opacity-100 transition-opacity ml-1 shrink-0 -mr-1" title="Закрыть" > × ))} ); } window.Toaster = Toaster; })();